[
  {
    "path": ".editorconfig",
    "content": "# https://editorconfig.org/\n# This configuration is used by ktlint when spotless invokes it\n\n[*.{kt,kts}]\nij_kotlin_allow_trailing_comma=true\nij_kotlin_allow_trailing_comma_on_call_site=true\nktlint_function_naming_ignore_when_annotated_with=Composable, Test\nktlint_standard_backing-property-naming = disabled\nktlint_standard_binary-expression-wrapping = disabled\nktlint_standard_chain-method-continuation = disabled\nktlint_standard_class-signature = disabled\nktlint_standard_condition-wrapping = disabled\nktlint_standard_function-expression-body = disabled\nktlint_standard_function-literal = disabled\nktlint_standard_function-type-modifier-spacing = disabled\nktlint_standard_multiline-loop = disabled\nktlint_standard_function-signature = disabled\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "content": "name: Bug Report\ndescription: File a bug report\ntitle: \"[Bug]: \"\nlabels: [\"bug\", \"triage me\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for taking the time to fill out this bug report!\n  - type: checkboxes\n    attributes:\n      label: Is there an existing issue for this?\n      description: Please search to see if an issue already exists for the bug you encountered.\n      options:\n      - label: I have searched the existing issues\n        required: true\n  - type: checkboxes\n    attributes:\n      label: Is there a StackOverflow question about this issue?\n      description: Please search [StackOverflow](https://stackoverflow.com/questions/tagged/android-jetpack) if an issue with an answer already exists for the bug you encountered.\n      options:\n      - label: I have searched StackOverflow\n        required: true\n  - type: textarea\n    id: what-happened\n    attributes:\n      label: What happened?\n      description: Also tell us, what did you expect to happen?\n      placeholder: Tell us what you see!\n      value: \"A bug happened!\"\n    validations:\n      required: true\n  - type: textarea\n    id: logs\n    attributes:\n      label: Relevant logcat output\n      description: Please copy and paste any relevant logcat output. This will be automatically formatted into code, so no need for backticks.\n      render: shell\n  - type: checkboxes\n    id: terms\n    attributes:\n      label: Code of Conduct\n      description: By submitting this issue, you agree to follow our [Code of Conduct](CODE_OF_CONDUCT.md)\n      options:\n        - label: I agree to follow this project's Code of Conduct\n          required: true\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/docs_issue.yml",
    "content": "name: Documentation issue\ndescription: File an issue or make a suggestion for the project documentation\ntitle: \"[Documentation]: \"\nlabels: [\"documentation\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for taking the time to improve our documentation!\n  - type: checkboxes\n    attributes:\n      label: Is there an existing issue for this?\n      description: Please search to see if an issue already exists for the documentation issue you encountered.\n      options:\n      - label: I have searched the existing issues\n        required: true\n  - type: input\n    id: page-url\n    attributes:\n      label: Page URL (type \"NEW\" for a new page suggestion)\n    validations:\n      required: true\n  - type: textarea\n    id: what-needs-improving\n    attributes:\n      label: What's the documentation problem or suggestion?\n      placeholder: Tell us what should be improved!\n      value: \"Docs need improving!\"\n    validations:\n      required: true\n  - type: checkboxes\n    id: terms\n    attributes:\n      label: Code of Conduct\n      description: By submitting this issue, you agree to follow our [Code of Conduct](CODE_OF_CONDUCT.md)\n      options:\n        - label: I agree to follow this project's Code of Conduct\n          required: true\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yml",
    "content": "name: Feature request\ndescription: File a feature request\ntitle: \"[FR]: \"\nlabels: [\"enhancement\", \"triage me\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for taking the time to fill out this bug report!\n  - type: checkboxes\n    attributes:\n      label: Is there an existing issue for this?\n      description: Please search to see if an issue already exists for this feature request.\n      options:\n      - label: I have searched the existing issues\n        required: true\n  - type: textarea\n    id: describe-problem\n    attributes:\n      label: Describe the problem\n      description: Is your feature request related to a problem? Please describe.\n      placeholder: I'm always frustrated when...\n    validations:\n      required: true\n  - type: textarea\n    id: solution\n    attributes:\n      label: Describe the solution\n      description: Please describe the solution you'd like. A clear and concise description of what you want to happen.\n    validations:\n      required: true\n  - type: textarea\n    id: context\n    attributes:\n      label: Additional context\n      description: Add any other context or screenshots about the feature request here.\n    validations:\n      required: false\n  - type: checkboxes\n    id: terms\n    attributes:\n      label: Code of Conduct\n      description: By submitting this issue, you agree to follow our [Code of Conduct](CODE_OF_CONDUCT.md)\n      options:\n        - label: I agree to follow this project's Code of Conduct\n          required: true\n"
  },
  {
    "path": ".github/ci-gradle.properties",
    "content": "#\n# Copyright 2020 The Android Open Source Project\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\norg.gradle.daemon=false\norg.gradle.parallel=true\norg.gradle.workers.max=2\norg.gradle.configuration-cache=true\norg.gradle.configuration-cache.parallel=true\n\nkotlin.incremental=false\n\n# Controls KotlinOptions.allWarningsAsErrors.\n# This value used in CI and is currently set to false.\n# If you want to treat warnings as errors locally, set this property to true\n# in your ~/.gradle/gradle.properties file.\nwarningsAsErrors=false\n"
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "**DO NOT CREATE A PULL REQUEST WITHOUT READING THESE INSTRUCTIONS**\n\n## Instructions\nThanks for submitting a pull request. To accept your pull request we need you do a few things: \n\n**If this is your first pull request**\n\n- [Sign the contributors license agreement](https://cla.developers.google.com/)\n\n**Ensure tests pass and code is formatted correctly**\n\n- Run local tests on the `DemoDebug` variant by running `./gradlew testDemoDebug`\n- Fix code formatting: `./gradlew spotlessApply`\n\n**Add a description**\n\nWe need to know what you've done and why you've done it. Include a summary of what your pull request contains, and why you have made these changes. Include links to any relevant issues which it fixes.\n\n[Here's an example](https://github.com/android/nowinandroid/pull/1257).\n\n**NOW DELETE THIS LINE AND EVERYTHING ABOVE IT**\n\n**What I have done and why**\n\n\\<add your PR description here\\> \n"
  },
  {
    "path": ".github/renovate.json",
    "content": "{\n  \"$schema\": \"https://docs.renovatebot.com/renovate-schema.json\",\n  \"extends\": [\n    \"local>android/.github:renovate-config\"\n  ],\n  \"baseBranches\": [\n    \"main\"\n  ],\n  \"gitIgnoredAuthors\": [\n    \"renovate[bot]@users.noreply.github.com\",\n    \"github-actions[bot]@users.noreply.github.com\",\n    \"41898282+github-actions[bot]@users.noreply.github.com\"\n  ]\n}\n"
  },
  {
    "path": ".github/workflows/Build.yaml",
    "content": "name: Build\n\non:\n  workflow_dispatch:\n  push:\n    branches:\n      - main\n  pull_request:\n\nconcurrency:\n  group: build-${{ github.ref }}\n  cancel-in-progress: true\n\njobs:\n  test_and_apk:\n    name: \"Local tests and APKs\"\n    runs-on: ubuntu-latest\n\n    permissions:\n      contents: write\n      pull-requests: write\n      security-events: write\n\n    timeout-minutes: 60\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Copy CI gradle.properties\n        run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties\n\n      - name: Set up JDK 21\n        uses: actions/setup-java@v5\n        with:\n          distribution: 'zulu'\n          java-version: 21\n\n      - name: Setup Gradle\n        uses: gradle/actions/setup-gradle@v4\n        with:\n          cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}\n          build-scan-publish: true\n          build-scan-terms-of-use-url: \"https://gradle.com/terms-of-service\"\n          build-scan-terms-of-use-agree: \"yes\"\n\n      - name: Check build-logic\n        run: ./gradlew :build-logic:convention:check\n\n      - name: Check spotless\n        run: ./gradlew spotlessCheck\n\n      - name: Check Dependency Guard\n        id: dependencyguard_verify\n        continue-on-error: true\n        run: ./gradlew dependencyGuard\n\n      - name: Prevent updating Dependency Guard baselines if this is a fork\n        id: checkfork_dependencyguard\n        continue-on-error: false\n        if: steps.dependencyguard_verify.outcome == 'failure' && github.event.pull_request.head.repo.full_name != github.repository\n        run: |\n          echo \"::error::Dependency Guard failed, please update baselines with: ./gradlew dependencyGuardBaseline\" && exit 1\n\n        # Runs if previous job failed\n      - name: Generate new Dependency Guard baselines if verification failed and it's a PR\n        id: dependencyguard_baseline\n        if: steps.dependencyguard_verify.outcome == 'failure' && github.event_name == 'pull_request'\n        run: |\n          ./gradlew dependencyGuardBaseline\n\n      - name: Push new Dependency Guard baselines if available\n        uses: stefanzweifel/git-auto-commit-action@v5\n        if: steps.dependencyguard_baseline.outcome == 'success'\n        with:\n          file_pattern: '**/dependencies/*.txt'\n          disable_globbing: true\n          commit_message: \"🤖 Updates baselines for Dependency Guard\"\n\n      - name: Update Graphs\n        run: ./gradlew graphUpdate\n        continue-on-error: true\n\n      - name: Check Graphs\n        id: graphs_verify\n        run: git add -- \"**/README.md\" && git diff --cached --quiet --exit-code -- \"**/README.md\"\n\n      - name: Prevent updating graphs if this is a fork\n        id: checkfork_graphs\n        continue-on-error: false\n        if: steps.graphs_verify.outcome == 'failure' && github.event.pull_request.head.repo.full_name != github.repository\n        run: |\n          echo \"::error::Check Graphs failed, please update graphs with: ./gradlew graphUpdate\" && exit 1\n\n      - name: Push new graphs if available\n        if: steps.graphs_verify.outcome == 'failure' && github.event_name == 'pull_request'\n        uses: stefanzweifel/git-auto-commit-action@v5\n        with:\n          file_pattern: '**/README.md'\n          disable_globbing: true\n          commit_message: \"🤖 Updates graphs\"\n\n      - name: Run all local screenshot tests (Roborazzi)\n        id: screenshotsverify\n        continue-on-error: true\n        run: ./gradlew verifyRoborazziDemoDebug\n\n      - name: Prevent pushing new screenshots if this is a fork\n        id: checkfork_screenshots\n        continue-on-error: false\n        if: steps.screenshotsverify.outcome == 'failure' && github.event.pull_request.head.repo.full_name != github.repository\n        run: |\n          echo \"::error::Screenshot tests failed, please create a PR in your fork first.\" \n          echo \"Your fork's CI will take screenshots for your fork.\"\n          exit 1\n\n      # Runs if previous job failed\n      - name: Generate new screenshots if verification failed and it's a PR\n        id: screenshotsrecord\n        if: steps.screenshotsverify.outcome == 'failure' && github.event_name == 'pull_request'\n        run: |\n          ./gradlew recordRoborazziDemoDebug\n\n      - name: Push new screenshots if available\n        uses: stefanzweifel/git-auto-commit-action@v5\n        if: steps.screenshotsrecord.outcome == 'success'\n        with:\n          file_pattern: '*/*.png'\n          disable_globbing: true\n          commit_message: \"🤖 Updates screenshots\"\n\n      # Run local tests after screenshot tests to avoid wrong UP-TO-DATE. TODO: Ignore screenshots.\n      - name: Run local tests\n        run: ./gradlew testDemoDebug :lint:test\n\n      - name: Build all build type and flavor permutations\n        run: ./gradlew :app:assemble -PminifyWithR8=false\n\n      - name: Upload build outputs (APKs)\n        uses: actions/upload-artifact@v4\n        with:\n          name: APKs\n          path: '**/build/outputs/apk/**/*.apk'\n\n      - name: Upload JVM local results (XML)\n        if: ${{ !cancelled() }}\n        uses: actions/upload-artifact@v4\n        with:\n          name: local-test-results\n          path: '**/build/test-results/test*UnitTest/**.xml'\n\n      - name: Upload screenshot results (PNG)\n        if: ${{ !cancelled() }}\n        uses: actions/upload-artifact@v4\n        with:\n          name: screenshot-test-results\n          path: '**/build/outputs/roborazzi/*_compare.png'\n\n      - name: Check lint\n        run: ./gradlew :app:lintProdRelease :app-nia-catalog:lintRelease :lint:lint\n\n      - name: Upload lint reports (HTML)\n        if: ${{ !cancelled() }}\n        uses: actions/upload-artifact@v4\n        with:\n          name: lint-reports\n          path: '**/build/reports/lint-results-*.html'\n\n      - name: Upload lint reports (SARIF) for app module\n        if: ${{ !cancelled() && hashFiles('app/**/*.sarif') != '' }}\n        uses: github/codeql-action/upload-sarif@v3\n        with:\n          sarif_file: './app/'\n          category: app\n\n      - name: Upload lint reports (SARIF) for app-nia-catalog module\n        if: ${{ !cancelled() && hashFiles('app-nia-catalog/**/*.sarif') != '' }}\n        uses: github/codeql-action/upload-sarif@v3\n        with:\n          sarif_file: './app-nia-catalog/'\n          category: app-nia-catalog\n\n      - name: Upload lint reports (SARIF) for lint module\n        if: ${{ !cancelled() && hashFiles('lint/**/*.sarif') != '' }}\n        uses: github/codeql-action/upload-sarif@v3\n        with:\n          sarif_file: './lint/'\n          category: lint\n\n      - name: Check badging\n        run: ./gradlew :app:checkProdReleaseBadging\n\n  androidTest:\n    runs-on: ubuntu-latest\n    timeout-minutes: 55\n    strategy:\n      matrix:\n        api-level: [26, 34]\n\n    steps:\n      - name: Delete unnecessary tools 🔧\n        uses: jlumbroso/free-disk-space@v1.3.1\n        with:\n          android: false # Don't remove Android tools\n          tool-cache: true # Remove image tool cache - rm -rf \"$AGENT_TOOLSDIRECTORY\"\n          dotnet: true # rm -rf /usr/share/dotnet\n          haskell: true # rm -rf /opt/ghc...\n          swap-storage: true # rm -f /mnt/swapfile (4GiB)\n          docker-images: false # Takes 16s, enable if needed in the future\n          large-packages: false # includes google-cloud-sdk and it's slow\n\n      - name: Enable KVM group perms\n        run: |\n          echo 'KERNEL==\"kvm\", GROUP=\"kvm\", MODE=\"0666\", OPTIONS+=\"static_node=kvm\"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules\n          sudo udevadm control --reload-rules\n          sudo udevadm trigger --name-match=kvm\n          ls /dev/kvm\n\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Copy CI gradle.properties\n        run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties\n\n      - name: Set up JDK 21\n        uses: actions/setup-java@v5\n        with:\n          distribution: 'zulu'\n          java-version: 21\n\n      - name: Setup Gradle\n        uses: gradle/actions/setup-gradle@v4\n        with:\n          cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}\n          build-scan-publish: true\n          build-scan-terms-of-use-url: \"https://gradle.com/terms-of-service\"\n          build-scan-terms-of-use-agree: \"yes\"\n\n      - name: Build projects and run instrumentation tests\n        uses: reactivecircus/android-emulator-runner@v2\n        with:\n          api-level: ${{ matrix.api-level }}\n          arch: x86_64\n          disable-animations: true\n          disk-size: 6000M\n          heap-size: 600M\n          script: ./gradlew connectedDemoDebugAndroidTest --daemon\n\n      - name: Run local tests (including Roborazzi) for the combined coverage report (only API 30)\n        if: matrix.api-level == 30\n        # There is no need to verify Roborazzi tests to generate coverage.\n        run: ./gradlew testDemoDebugUnitTest -Proborazzi.test.verify=false # Add Prod if we ever add JVM tests for prod\n\n      # Add `createProdDebugUnitTestCoverageReport` if we ever add JVM tests for prod\n      - name: Generate coverage reports for Debug variants (only API 30)\n        if: matrix.api-level == 30\n        run: ./gradlew createDemoDebugCombinedCoverageReport\n        \n      - name: Upload test reports\n        if: ${{ !cancelled() }}\n        uses: actions/upload-artifact@v4\n        with:\n          name: test-reports-${{ matrix.api-level }}\n          path: '**/build/reports/androidTests'\n\n      - name: Display local test coverage (only API 30)\n        if: matrix.api-level == 30\n        id: jacoco\n        uses: madrapps/jacoco-report@v1.7.1\n        with:\n          title: Combined test coverage report\n          min-coverage-overall: 40\n          min-coverage-changed-files: 60\n          paths: |\n            ${{ github.workspace }}/**/build/reports/jacoco/**/*Report.xml\n          token: ${{ secrets.GITHUB_TOKEN }}\n        \n      - name: Upload local coverage reports (XML + HTML) (only API 30)\n        if: matrix.api-level == 30\n        uses: actions/upload-artifact@v4\n        with:\n          name: coverage-reports\n          if-no-files-found: error\n          compression-level: 1\n          overwrite: false\n          path: '**/build/reports/jacoco/'\n"
  },
  {
    "path": ".github/workflows/NightlyBaselineProfiles.yaml",
    "content": "name: NightlyBaselineProfiles\n\non:\n  workflow_dispatch:\n  schedule:\n    - cron:  '42 4 * * *'\n\njobs:\n  baseline_profiles:\n    name: \"Generate Baseline Profiles\"\n    if: github.repository == 'android/nowinandroid'\n    runs-on: ubuntu-latest\n\n    permissions:\n      contents: write\n\n    timeout-minutes: 60\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Enable KVM group perms\n        run: |\n          echo 'KERNEL==\"kvm\", GROUP=\"kvm\", MODE=\"0666\", OPTIONS+=\"static_node=kvm\"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules\n          sudo udevadm control --reload-rules\n          sudo udevadm trigger --name-match=kvm\n          ls /dev/kvm\n\n      - name: Copy CI gradle.properties\n        run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties\n\n      - name: Set up JDK 17\n        uses: actions/setup-java@v5\n        with:\n          distribution: 'zulu'\n          java-version: 17\n\n      - name: Setup Gradle\n        uses: gradle/actions/setup-gradle@v4\n        with:\n          cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}\n          build-scan-publish: true\n          build-scan-terms-of-use-url: \"https://gradle.com/terms-of-service\"\n          build-scan-terms-of-use-agree: \"yes\"\n\n      - name: Setup Android SDK\n        uses: android-actions/setup-android@v3\n\n      - name: Accept licenses\n        run: yes | sdkmanager --licenses || true\n\n      - name: Check build-logic\n        run: ./gradlew :build-logic:convention:check\n\n      - name: Setup GMD\n        run: ./gradlew :benchmarks:pixel6Api33Setup\n          --info\n          -Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true\n          -Pandroid.testoptions.manageddevices.emulator.gpu=\"swiftshader_indirect\"\n\n      - name: Build all build type and flavor permutations including baseline profiles\n        run: ./gradlew :app:assemble\n             -Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.enabledRules=baselineprofile\n             -Pandroid.testoptions.manageddevices.emulator.gpu=\"swiftshader_indirect\"\n             -Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true\n"
  },
  {
    "path": ".github/workflows/Release.yml",
    "content": "name: GitHub Release with APKs\n\non:\n  workflow_dispatch:\n  push:\n    tags:\n    - 'v*'\n\njobs:\n  build:\n    if: github.repository == 'android/nowinandroid'\n    runs-on: ubuntu-latest\n    timeout-minutes: 120\n\n    steps:\n      - name: Enable KVM group perms\n        run: |\n          echo 'KERNEL==\"kvm\", GROUP=\"kvm\", MODE=\"0666\", OPTIONS+=\"static_node=kvm\"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules\n          sudo udevadm control --reload-rules\n          sudo udevadm trigger --name-match=kvm\n          ls /dev/kvm\n\n      - name: Checkout\n\n        uses: actions/checkout@v4\n\n      - name: Copy CI gradle.properties\n        run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties\n\n      - name: Set up JDK 17\n        uses: actions/setup-java@v5\n        with:\n          distribution: 'zulu'\n          java-version: 17\n\n      - name: Setup Gradle\n        uses: gradle/actions/setup-gradle@v4\n        with:\n          cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}\n          build-scan-publish: true\n          build-scan-terms-of-use-url: \"https://gradle.com/terms-of-service\"\n          build-scan-terms-of-use-agree: \"yes\"\n\n      - name: Setup Android SDK\n        uses: android-actions/setup-android@v3\n\n      - name: Accept licenses\n        run: yes | sdkmanager --licenses || true\n\n      - name: Setup GMD\n        run: ./gradlew :benchmarks:pixel6Api33Setup\n          --info\n          -Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true\n          -Pandroid.testoptions.manageddevices.emulator.gpu=\"swiftshader_indirect\"\n\n      - name: Build release variant including baseline profile generation\n        run: ./gradlew :app:assembleDemoRelease\n             -Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.enabledRules=BaselineProfile\n             -Pandroid.testoptions.manageddevices.emulator.gpu=\"swiftshader_indirect\"\n             -Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true\n             -Pandroid.experimental.androidTest.numManagedDeviceShards=1\n             -Pandroid.experimental.testOptions.managedDevices.maxConcurrentDevices=1\n      - name: Create Release\n        id: create_release\n        uses: actions/create-release@v1\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        with:\n          tag_name: ${{ github.ref }}\n          release_name: ${{ github.ref }}\n          draft: true\n          prerelease: false\n\n      - name: Upload app\n        uses: actions/upload-release-asset@v1\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        with:\n          upload_url: ${{ steps.create_release.outputs.upload_url }}\n          asset_path: app/build/outputs/apk/demo/release/app-demo-release.apk\n          asset_name: app-demo-release.apk\n          asset_content_type: application/vnd.android.package-archive\n"
  },
  {
    "path": ".gitignore",
    "content": "# built application files\n*.apk\n*.ap_\n\n# files for the dex VM\n*.dex\n\n# Java class files\n*.class\n\n# generated files\nbin/\ngen/\nout/\nbuild/\ngenerated/\n\n# Local configuration file (sdk path, etc)\nlocal.properties\n\n# Eclipse project files\n.classpath\n.project\n\n# Windows thumbnail db\n.DS_Store\n\n# IDEA/Android Studio project files, because\n# the project can be imported from settings.gradle.kts\n*.iml\n.idea/*\n!.idea/copyright\n# Keep the code styles.\n!/.idea/codeStyles\n/.idea/codeStyles/*\n!/.idea/codeStyles/Project.xml\n!/.idea/codeStyles/codeStyleConfig.xml\n\n# Gradle cache\n.gradle\n\n# Sandbox stuff\n_sandbox\n\n# Android Studio captures folder\ncaptures/\n\n# Kotlin\n.kotlin\n"
  },
  {
    "path": ".google/BUILDME",
    "content": "# This file can be used to trigger an internal build by changing the number below\n2\n"
  },
  {
    "path": ".google/packaging.yaml",
    "content": "# Copyright (C) 2022 The Android Open Source Project\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     https://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n# GOOGLE SAMPLE PACKAGING DATA\n#\n# This file is used by Google as part of our samples packaging process.\n# End users may safely ignore this file. It has no relevance to other systems.\n---\nstatus:       PUBLISHED\ntechnologies: [Android, JetpackCompose, Coroutines]\ncategories:\n  - AndroidTesting\n  - AndroidArchitecture\n  - AndroidArchitectureUILayer\n  - AndroidArchitectureDomainLayer\n  - AndroidArchitectureDataLayer\n  - AndroidArchitectureStateProduction\n  - AndroidArchitectureStateHolder\n  - JetpackComposeTesting\n  - JetpackComposeA11y\n  - JetpackComposeArchitectureAndState\n  - JetpackComposeDesignSystems\n  - JetpackComposeNavigation\n  - JetpackComposeAnimation\nsolutions:\n  - Mobile\n  - Flow\n  - JetpackHilt\n  - JetpackDataStore\n  - JetpackRoom\n  - JetpackNavigation\n  - JetpackWorkManager\n  - JetpackLifecycle\nlanguages:    [Kotlin]\ngithub:       android/nowinandroid\nlevel:        ADVANCED\nlicense: apache2\n"
  },
  {
    "path": ".idea/codeStyles/Project.xml",
    "content": "<component name=\"ProjectCodeStyleConfiguration\">\n  <code_scheme name=\"Project\" version=\"173\">\n    <JavaCodeStyleSettings>\n      <option name=\"ANNOTATION_PARAMETER_WRAP\" value=\"1\" />\n      <option name=\"CLASS_COUNT_TO_USE_IMPORT_ON_DEMAND\" value=\"5\" />\n      <option name=\"NAMES_COUNT_TO_USE_IMPORT_ON_DEMAND\" value=\"3\" />\n      <option name=\"PACKAGES_TO_USE_IMPORT_ON_DEMAND\">\n        <value>\n          <package name=\"java.awt\" withSubpackages=\"false\" static=\"false\" />\n          <package name=\"javax.swing\" withSubpackages=\"false\" static=\"false\" />\n        </value>\n      </option>\n      <option name=\"IMPORT_LAYOUT_TABLE\">\n        <value>\n          <package name=\"\" withSubpackages=\"true\" static=\"false\" />\n          <emptyLine />\n          <package name=\"javax\" withSubpackages=\"true\" static=\"false\" />\n          <package name=\"java\" withSubpackages=\"true\" static=\"false\" />\n          <emptyLine />\n          <package name=\"\" withSubpackages=\"true\" static=\"true\" />\n        </value>\n      </option>\n      <option name=\"JD_P_AT_EMPTY_LINES\" value=\"false\" />\n      <option name=\"JD_DO_NOT_WRAP_ONE_LINE_COMMENTS\" value=\"true\" />\n      <option name=\"JD_KEEP_EMPTY_PARAMETER\" value=\"false\" />\n      <option name=\"JD_KEEP_EMPTY_EXCEPTION\" value=\"false\" />\n      <option name=\"JD_KEEP_EMPTY_RETURN\" value=\"false\" />\n      <option name=\"JD_PRESERVE_LINE_FEEDS\" value=\"true\" />\n    </JavaCodeStyleSettings>\n    <JetCodeStyleSettings>\n      <option name=\"NAME_COUNT_TO_USE_STAR_IMPORT\" value=\"99\" />\n      <option name=\"NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS\" value=\"99\" />\n      <option name=\"IMPORT_NESTED_CLASSES\" value=\"true\" />\n      <option name=\"CODE_STYLE_DEFAULTS\" value=\"KOTLIN_OFFICIAL\" />\n    </JetCodeStyleSettings>\n    <Properties>\n      <option name=\"KEEP_BLANK_LINES\" value=\"true\" />\n    </Properties>\n    <XML>\n      <option name=\"XML_ATTRIBUTE_WRAP\" value=\"2\" />\n    </XML>\n    <ADDITIONAL_INDENT_OPTIONS fileType=\"java\">\n      <option name=\"TAB_SIZE\" value=\"8\" />\n    </ADDITIONAL_INDENT_OPTIONS>\n    <ADDITIONAL_INDENT_OPTIONS fileType=\"js\">\n      <option name=\"CONTINUATION_INDENT_SIZE\" value=\"4\" />\n    </ADDITIONAL_INDENT_OPTIONS>\n    <codeStyleSettings language=\"JAVA\">\n      <option name=\"ALIGN_MULTILINE_PARAMETERS\" value=\"false\" />\n      <option name=\"ALIGN_MULTILINE_FOR\" value=\"false\" />\n      <option name=\"CALL_PARAMETERS_WRAP\" value=\"1\" />\n      <option name=\"PREFER_PARAMETERS_WRAP\" value=\"true\" />\n      <option name=\"METHOD_PARAMETERS_WRAP\" value=\"1\" />\n      <option name=\"RESOURCE_LIST_WRAP\" value=\"1\" />\n      <option name=\"EXTENDS_LIST_WRAP\" value=\"1\" />\n      <option name=\"THROWS_LIST_WRAP\" value=\"1\" />\n      <option name=\"EXTENDS_KEYWORD_WRAP\" value=\"1\" />\n      <option name=\"THROWS_KEYWORD_WRAP\" value=\"1\" />\n      <option name=\"METHOD_CALL_CHAIN_WRAP\" value=\"1\" />\n      <option name=\"BINARY_OPERATION_WRAP\" value=\"1\" />\n      <option name=\"BINARY_OPERATION_SIGN_ON_NEXT_LINE\" value=\"true\" />\n      <option name=\"TERNARY_OPERATION_WRAP\" value=\"1\" />\n      <option name=\"TERNARY_OPERATION_SIGNS_ON_NEXT_LINE\" value=\"true\" />\n      <option name=\"FOR_STATEMENT_WRAP\" value=\"1\" />\n      <option name=\"ARRAY_INITIALIZER_WRAP\" value=\"1\" />\n      <option name=\"ASSIGNMENT_WRAP\" value=\"1\" />\n      <option name=\"IF_BRACE_FORCE\" value=\"3\" />\n      <option name=\"DOWHILE_BRACE_FORCE\" value=\"3\" />\n      <option name=\"WHILE_BRACE_FORCE\" value=\"3\" />\n      <option name=\"FOR_BRACE_FORCE\" value=\"3\" />\n      <option name=\"WRAP_LONG_LINES\" value=\"true\" />\n      <option name=\"PARAMETER_ANNOTATION_WRAP\" value=\"1\" />\n      <option name=\"VARIABLE_ANNOTATION_WRAP\" value=\"1\" />\n      <option name=\"ENUM_CONSTANTS_WRAP\" value=\"1\" />\n    </codeStyleSettings>\n    <codeStyleSettings language=\"JSON\">\n      <indentOptions>\n        <option name=\"CONTINUATION_INDENT_SIZE\" value=\"4\" />\n        <option name=\"TAB_SIZE\" value=\"2\" />\n      </indentOptions>\n    </codeStyleSettings>\n    <codeStyleSettings language=\"XML\">\n      <option name=\"FORCE_REARRANGE_MODE\" value=\"1\" />\n      <indentOptions>\n        <option name=\"CONTINUATION_INDENT_SIZE\" value=\"4\" />\n      </indentOptions>\n      <arrangement>\n        <rules>\n          <section>\n            <rule>\n              <match>\n                <AND>\n                  <NAME>xmlns:android</NAME>\n                  <XML_ATTRIBUTE />\n                  <XML_NAMESPACE>^$</XML_NAMESPACE>\n                </AND>\n              </match>\n            </rule>\n          </section>\n          <section>\n            <rule>\n              <match>\n                <AND>\n                  <NAME>xmlns:.*</NAME>\n                  <XML_ATTRIBUTE />\n                  <XML_NAMESPACE>^$</XML_NAMESPACE>\n                </AND>\n              </match>\n              <order>BY_NAME</order>\n            </rule>\n          </section>\n          <section>\n            <rule>\n              <match>\n                <AND>\n                  <NAME>.*:id</NAME>\n                  <XML_ATTRIBUTE />\n                  <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>\n                </AND>\n              </match>\n            </rule>\n          </section>\n          <section>\n            <rule>\n              <match>\n                <AND>\n                  <NAME>.*:name</NAME>\n                  <XML_ATTRIBUTE />\n                  <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>\n                </AND>\n              </match>\n            </rule>\n          </section>\n          <section>\n            <rule>\n              <match>\n                <AND>\n                  <NAME>name</NAME>\n                  <XML_ATTRIBUTE />\n                  <XML_NAMESPACE>^$</XML_NAMESPACE>\n                </AND>\n              </match>\n            </rule>\n          </section>\n          <section>\n            <rule>\n              <match>\n                <AND>\n                  <NAME>style</NAME>\n                  <XML_ATTRIBUTE />\n                  <XML_NAMESPACE>^$</XML_NAMESPACE>\n                </AND>\n              </match>\n            </rule>\n          </section>\n          <section>\n            <rule>\n              <match>\n                <AND>\n                  <NAME>.*:layout_width</NAME>\n                  <XML_ATTRIBUTE />\n                  <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>\n                </AND>\n              </match>\n            </rule>\n          </section>\n          <section>\n            <rule>\n              <match>\n                <AND>\n                  <NAME>.*:layout_height</NAME>\n                  <XML_ATTRIBUTE />\n                  <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>\n                </AND>\n              </match>\n            </rule>\n          </section>\n          <section>\n            <rule>\n              <match>\n                <AND>\n                  <NAME>.*:layout_.*</NAME>\n                  <XML_ATTRIBUTE />\n                  <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>\n                </AND>\n              </match>\n              <order>BY_NAME</order>\n            </rule>\n          </section>\n          <section>\n            <rule>\n              <match>\n                <AND>\n                  <NAME>.*:width</NAME>\n                  <XML_ATTRIBUTE />\n                  <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>\n                </AND>\n              </match>\n              <order>BY_NAME</order>\n            </rule>\n          </section>\n          <section>\n            <rule>\n              <match>\n                <AND>\n                  <NAME>.*:height</NAME>\n                  <XML_ATTRIBUTE />\n                  <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>\n                </AND>\n              </match>\n              <order>BY_NAME</order>\n            </rule>\n          </section>\n          <section>\n            <rule>\n              <match>\n                <AND>\n                  <NAME>.*:viewportWidth</NAME>\n                  <XML_ATTRIBUTE />\n                  <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>\n                </AND>\n              </match>\n            </rule>\n          </section>\n          <section>\n            <rule>\n              <match>\n                <AND>\n                  <NAME>.*:viewportHeight</NAME>\n                  <XML_ATTRIBUTE />\n                  <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>\n                </AND>\n              </match>\n            </rule>\n          </section>\n          <section>\n            <rule>\n              <match>\n                <AND>\n                  <NAME>.*</NAME>\n                  <XML_ATTRIBUTE />\n                  <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>\n                </AND>\n              </match>\n              <order>BY_NAME</order>\n            </rule>\n          </section>\n          <section>\n            <rule>\n              <match>\n                <AND>\n                  <NAME>.*:layout_.*</NAME>\n                  <XML_ATTRIBUTE />\n                  <XML_NAMESPACE>http://schemas.android.com/apk/res-auto</XML_NAMESPACE>\n                </AND>\n              </match>\n              <order>BY_NAME</order>\n            </rule>\n          </section>\n          <section>\n            <rule>\n              <match>\n                <AND>\n                  <NAME>.*</NAME>\n                  <XML_ATTRIBUTE />\n                  <XML_NAMESPACE>http://schemas.android.com/apk/res-auto</XML_NAMESPACE>\n                </AND>\n              </match>\n              <order>BY_NAME</order>\n            </rule>\n          </section>\n          <section>\n            <rule>\n              <match>\n                <AND>\n                  <NAME>.*:layout_.*</NAME>\n                  <XML_ATTRIBUTE />\n                  <XML_NAMESPACE>.*</XML_NAMESPACE>\n                </AND>\n              </match>\n              <order>BY_NAME</order>\n            </rule>\n          </section>\n          <section>\n            <rule>\n              <match>\n                <AND>\n                  <NAME>.*</NAME>\n                  <XML_ATTRIBUTE />\n                  <XML_NAMESPACE>.*</XML_NAMESPACE>\n                </AND>\n              </match>\n              <order>BY_NAME</order>\n            </rule>\n          </section>\n        </rules>\n      </arrangement>\n    </codeStyleSettings>\n    <codeStyleSettings language=\"kotlin\">\n      <option name=\"CODE_STYLE_DEFAULTS\" value=\"KOTLIN_OFFICIAL\" />\n      <option name=\"KEEP_BLANK_LINES_IN_DECLARATIONS\" value=\"1\" />\n      <option name=\"KEEP_BLANK_LINES_IN_CODE\" value=\"1\" />\n      <option name=\"KEEP_BLANK_LINES_BEFORE_RBRACE\" value=\"0\" />\n      <option name=\"ALIGN_MULTILINE_PARAMETERS\" value=\"false\" />\n      <option name=\"FIELD_ANNOTATION_WRAP\" value=\"1\" />\n      <option name=\"PARAMETER_ANNOTATION_WRAP\" value=\"1\" />\n      <option name=\"VARIABLE_ANNOTATION_WRAP\" value=\"1\" />\n      <option name=\"ENUM_CONSTANTS_WRAP\" value=\"5\" />\n      <indentOptions>\n        <option name=\"CONTINUATION_INDENT_SIZE\" value=\"4\" />\n      </indentOptions>\n    </codeStyleSettings>\n  </code_scheme>\n</component>"
  },
  {
    "path": ".idea/codeStyles/codeStyleConfig.xml",
    "content": "<component name=\"ProjectCodeStyleConfiguration\">\n  <state>\n    <option name=\"USE_PER_PROJECT_SETTINGS\" value=\"true\" />\n  </state>\n</component>"
  },
  {
    "path": ".idea/copyright/The_Android_Open_Source_Project.xml",
    "content": "<component name=\"CopyrightManager\">\n  <copyright>\n    <option name=\"notice\" value=\"Copyright &amp;#36;today.year The Android Open Source Project&#10;&#10;Licensed under the Apache License, Version 2.0 (the &quot;License&quot;);&#10;you may not use this file except in compliance with the License.&#10;You may obtain a copy of the License at&#10;&#10;    https://www.apache.org/licenses/LICENSE-2.0&#10;&#10;Unless required by applicable law or agreed to in writing, software&#10;distributed under the License is distributed on an &quot;AS IS&quot; BASIS,&#10;WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.&#10;See the License for the specific language governing permissions and&#10;limitations under the License.\" />\n    <option name=\"myName\" value=\"The Android Open Source Project\" />\n  </copyright>\n</component>\n"
  },
  {
    "path": ".idea/copyright/profiles_settings.xml",
    "content": "<component name=\"CopyrightManager\">\n  <settings default=\"The Android Open Source Project\" />\n</component>"
  },
  {
    "path": ".run/Generate Demo Baseline Profile.run.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<component name=\"ProjectRunConfigurationManager\">\n  <!--\n  Baseline Profiles improve code execution speed by around 30% from the first launch by avoiding\n  interpretation and just-in-time (JIT) compilation steps for included code paths.\n  More information at http://d.android.com/baseline-profiles.\n\n  In this run configuration we leverage rerun parameter that always reruns the requested task regardless of cache.\n  We also leverage enable-display parameter to be able to verify the generator works as intended.\n  -->\n  <configuration default=\"false\" name=\"Generate Demo Baseline Profile\" type=\"GradleRunConfiguration\" factoryName=\"Gradle\">\n    <ExternalSystemSettings>\n      <option name=\"executionName\" />\n      <option name=\"externalProjectPath\" value=\"$PROJECT_DIR$\" />\n      <option name=\"externalSystemIdString\" value=\"GRADLE\" />\n      <option name=\"scriptParameters\" value=\"-Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.enabledRules=BaselineProfile\" />\n      <option name=\"taskDescriptions\">\n        <list />\n      </option>\n      <option name=\"taskNames\">\n        <list>\n          <option value=\":benchmark:pixel6Api31atdDemoBenchmarkAndroidTest\" />\n          <option value=\"--rerun\" />\n          <option value=\"--enable-display\" />\n        </list>\n      </option>\n      <option name=\"vmOptions\" />\n    </ExternalSystemSettings>\n    <ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>\n    <ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>\n    <DebugAllEnabled>false</DebugAllEnabled>\n    <method v=\"2\" />\n  </configuration>\n</component>\n"
  },
  {
    "path": ".run/spotlessApply.run.xml",
    "content": "<component name=\"ProjectRunConfigurationManager\">\n  <configuration default=\"false\" name=\"spotlessApply\" type=\"GradleRunConfiguration\" factoryName=\"Gradle\">\n    <ExternalSystemSettings>\n      <option name=\"executionName\" />\n      <option name=\"externalProjectPath\" value=\"$PROJECT_DIR$\" />\n      <option name=\"externalSystemIdString\" value=\"GRADLE\" />\n      <option name=\"taskDescriptions\">\n        <list />\n      </option>\n      <option name=\"taskNames\">\n        <list>\n          <option value=\"spotlessApply\" />\n        </list>\n      </option>\n      <option name=\"vmOptions\" />\n    </ExternalSystemSettings>\n    <ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>\n    <ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>\n    <DebugAllEnabled>false</DebugAllEnabled>\n    <method v=\"2\" />\n  </configuration>\n</component>"
  },
  {
    "path": "AGENTS.md",
    "content": "# Now in Android Project\n\nNow in Android is a native Android mobile application written in Kotlin. It provides regular news\nabout Android development. Users can choose to follow topics, be notified when new content is\navailable, and bookmark items.\n\n## Architecture\n\nThis 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:\n\n-   **UI:** Built entirely with Jetpack Compose, including Material 3 components and adaptive layouts for different screen sizes.\n-   **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.\n-   **Dependency Injection:** Hilt is used for dependency injection throughout the app, simplifying the management of dependencies and improving testability.\n-   **Navigation:** Navigation is handled by Jetpack Navigation 2 for Compose, allowing for a declarative and type-safe way to navigate between screens.\n-   **Data:** The data layer is implemented using the repository pattern.\n    -   **Local Data:** Room and DataStore are used for local data persistence.\n    -   **Remote Data:** Retrofit and OkHttp are used for fetching data from the network.\n-   **Background Processing:** WorkManager is used for deferrable background tasks.\n\n## Modules\n\nThe main Android app lives in the `app/` folder. Feature modules live in `feature/` and core and shared modules in `core/`.\n\n## Commands to Build & Test\n\nThe app and Android libraries have two product flavors: `demo` and `prod`, and two build types: `debug` and `release`.\n\n- Build: `./gradlew assemble{Variant}`. Typically `assembleDemoDebug`.\n- Fix linting/formatting: `./gradlew spotlessApply`\n- Run local tests: `./gradlew {variant}Test`\n- Run single test: `./gradlew {variant}Test --tests \"com.example.myapp.MyTestClass\"`\n- Run local screenshot tests: `./gradlew verifyRoborazziDemoDebug`\n\n### Instrumented tests\n\n- Gradle-managed devices to run on device tests: `./gradlew pixel6api31aospDebugAndroidTest`. Also `pixel4api30aospatdDebugAndroidTest` and `pixelcapi30aospatdDebugAndroidTest`.\n\n### Creating tests\n\n#### Instrumented tests\n\n- Tests for UI features should only use `ComposeTestRule` with a `ComponentActivity`.\n- Bigger tests live in the `:app` module and they can start activities like `MainActivity`.\n\n#### Local tests\n\n- [kotlinx.coroutines](https://github.com/Kotlin/kotlinx.coroutines) for most assertions\n- [cashapp/turbine](https://github.com/cashapp/turbine) for complex coroutine tests\n- [google/truth](https://github.com/google/truth) for assertions\n\n## Continuous integration\n\n- The workflows are defined in `.github/workflows/*.yaml` and they contain various checks.\n- Screenshot tests are generated by CI, so they shouldn't be checked into the repo from a workstation.\n\n## Version control and code location\n\n- The project uses git and is hosted in https://github.com/android/nowinandroid.\n"
  },
  {
    "path": "CODEOWNERS",
    "content": "* @dturner\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Google Open Source Community Guidelines\n\nAt Google, we recognize and celebrate the creativity and collaboration of open\nsource contributors and the diversity of skills, experiences, cultures, and\nopinions they bring to the projects and communities they participate in.\n\nEvery one of Google's open source projects and communities are inclusive\nenvironments, based on treating all individuals respectfully, regardless of\ngender identity and expression, sexual orientation, disabilities,\nneurodiversity, physical appearance, body size, ethnicity, nationality, race,\nage, religion, or similar personal characteristic.\n\nWe value diverse opinions, but we value respectful behavior more.\n\nRespectful behavior includes:\n\n* Being considerate, kind, constructive, and helpful.\n* Not engaging in demeaning, discriminatory, harassing, hateful, sexualized, or\n  physically threatening behavior, speech, and imagery.\n* Not engaging in unwanted physical contact.\n\nSome Google open source projects [may adopt][] an explicit project code of\nconduct, which may have additional detailed expectations for participants. Most\nof those projects will use our [modified Contributor Covenant][].\n\n[may adopt]: https://opensource.google/docs/releasing/preparing/#conduct\n[modified Contributor Covenant]: https://opensource.google/docs/releasing/template/CODE_OF_CONDUCT/\n\n## Resolve peacefully\n\nWe do not believe that all conflict is necessarily bad; healthy debate and\ndisagreement often yields positive results. However, it is never okay to be\ndisrespectful.\n\nIf you see someone behaving disrespectfully, you are encouraged to address the\nbehavior directly with those involved. Many issues can be resolved quickly and\neasily, and this gives people more control over the outcome of their dispute.\nIf you are unable to resolve the matter for any reason, or if the behavior is\nthreatening or harassing, report it. We are dedicated to providing an\nenvironment where participants feel welcome and safe.\n\n## Reporting problems\n\nSome Google open source projects may adopt a project-specific code of conduct.\nIn those cases, a Google employee will be identified as the Project Steward,\nwho will receive and handle reports of code of conduct violations. In the event\nthat a project hasn’t identified a Project Steward, you can report problems by\nemailing opensource@google.com.\n\nWe will investigate every complaint, but you may not receive a direct response.\nWe will use our discretion in determining when and how to follow up on reported\nincidents, which may range from not taking action to permanent expulsion from\nthe project and project-sponsored spaces. We will notify the accused of the\nreport and provide them an opportunity to discuss it before any action is\ntaken. The identity of the reporter will be omitted from the details of the\nreport supplied to the accused. In potentially harmful situations, such as\nongoing harassment or threats to anyone's safety, we may take action without\nnotice.\n\n*This document was adapted from the [IndieWeb Code of Conduct][] and can also\nbe found at <https://opensource.google/conduct/>.*\n\n[IndieWeb Code of Conduct]: https://indieweb.org/code-of-conduct\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# How to become a contributor and submit your own code\n\n## Contributor License Agreements\n\nWe'd love to accept your sample apps and patches! Before we can take them, we\nhave to jump a couple of legal hurdles.\n\nPlease fill out either the individual or corporate Contributor License Agreement\n(CLA).\n\n  * If you are an individual writing original source code and you're sure you\n    own the intellectual property, then you'll need to sign an [individual CLA](https://developers.google.com/open-source/cla/individual).\n  * If you work for a company that wants to allow you to contribute your work,\n    then you'll need to sign a [corporate CLA](https://developers.google.com/open-source/cla/corporate).\n\nFollow either of the two links above to access the appropriate CLA and\ninstructions for how to sign and return it. Once we receive it, we'll be able to\naccept your pull requests.\n\n## Contributing A Patch\n\n1. Submit an issue describing your proposed change to the repo in question.\n1. The repo owner will respond to your issue promptly.\n1. If your proposed change is accepted, and you haven't already done so, sign a\n   Contributor License Agreement (see details above).\n1. Fork the desired repo, develop and test your code changes.\n1. Ensure that your code adheres to the existing style in the sample to which\n   you are contributing. Refer to the\n   [Google Cloud Platform Samples Style Guide](https://github.com/GoogleCloudPlatform/Template/wiki/style.html) for the\n   recommended coding standards for this organization.\n1. Ensure that your code has an appropriate set of unit tests which all pass.\n1. Submit a pull request.\n\n"
  },
  {
    "path": "LICENSE",
    "content": "\n                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "README.md",
    "content": "![Now in Android](docs/images/nia-splash.jpg \"Now in Android\")\n\n<a href=\"https://play.google.com/store/apps/details?id=com.google.samples.apps.nowinandroid\"><img src=\"https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png\" height=\"70\"></a>\n\nNow in Android App\n==================\n\n**Learn how this app was designed and built in the [design case study](https://goo.gle/nia-figma), [architecture learning journey](docs/ArchitectureLearningJourney.md) and [modularization learning journey](docs/ModularizationLearningJourney.md).**\n\nThis is the repository for the [Now in Android](https://developer.android.com/series/now-in-android)\napp. It is a **work in progress** 🚧.\n\n**Now in Android** is a fully functional Android app built entirely with Kotlin and Jetpack Compose. It\nfollows Android design and development best practices and is intended to be a useful reference\nfor developers. As a running app, it's intended to help developers keep up-to-date with the world\nof Android development by providing regular news updates.\n\nThe app is currently in development. The `prodRelease` variant is [available on the Play Store](https://play.google.com/store/apps/details?id=com.google.samples.apps.nowinandroid).\n\n# Features\n\n**Now in Android** displays content from the\n[Now in Android](https://developer.android.com/series/now-in-android) series. Users can browse for\nlinks to recent videos, articles and other content. Users can also follow topics they are interested\nin, and be notified when new content is published which matches interests they are following.\n\n## Screenshots\n\n![Screenshot showing For You screen, Interests screen and Topic detail screen](docs/images/screenshots.png \"Screenshot showing For You screen, Interests screen and Topic detail screen\")\n\n# Development Environment\n\n**Now in Android** uses the Gradle build system and can be imported directly into Android Studio (make sure you are using the latest stable version available [here](https://developer.android.com/studio)). \n\nChange the run configuration to `app`.\n\n![image](https://user-images.githubusercontent.com/873212/210559920-ef4a40c5-c8e0-478b-bb00-4879a8cf184a.png)\n\nThe `demoDebug` and `demoRelease` build variants can be built and run (the `prod` variants use a backend server which is not currently publicly available).\n\n![image](https://user-images.githubusercontent.com/873212/210560507-44045dc5-b6d5-41ca-9746-f0f7acf22f8e.png)\n\nOnce you're up and running, you can refer to the learning journeys below to get a better\nunderstanding of which libraries and tools are being used, the reasoning behind the approaches to\nUI, testing, architecture and more, and how all of these different pieces of the project fit\ntogether to create a complete app.\n\n# Architecture\n\nThe **Now in Android** app follows the\n[official architecture guidance](https://developer.android.com/topic/architecture) \nand is described in detail in the\n[architecture learning journey](docs/ArchitectureLearningJourney.md).\n\n# Modularization\n\nThe **Now in Android** app has been fully modularized and you can find the detailed guidance and\ndescription of the modularization strategy used in\n[modularization learning journey](docs/ModularizationLearningJourney.md).\n\n# Build\n\nThe app contains the usual `debug` and `release` build variants. \n\nIn addition, the `benchmark` variant of `app` is used to test startup performance and generate a\nbaseline profile (see below for more information).\n\n`app-nia-catalog` is a standalone app that displays the list of components that are stylized for\n**Now in Android**.\n\nThe app also uses\n[product flavors](https://developer.android.com/studio/build/build-variants#product-flavors) to\ncontrol where content for the app should be loaded from.\n\nThe `demo` flavor uses static local data to allow immediate building and exploring of the UI.\n\nThe `prod` flavor makes real network calls to a backend server, providing up-to-date content. At \nthis time, there is not a public backend available.\n\nFor normal development use the `demoDebug` variant. For UI performance testing use the\n`demoRelease` variant. \n\n# Testing\n\nTo facilitate testing of components, **Now in Android** uses dependency injection with\n[Hilt](https://developer.android.com/training/dependency-injection/hilt-android).\n\nMost data layer components are defined as interfaces.\nThen, concrete implementations (with various dependencies) are bound to provide those interfaces to\nother components in the app.\nIn tests, **Now in Android** notably does _not_ use any mocking libraries.\nInstead, the production implementations can be replaced with test doubles using Hilt's testing APIs\n(or via manual constructor injection for `ViewModel` tests).\n\nThese test doubles implement the same interface as the production implementations and generally\nprovide a simplified (but still realistic) implementation with additional testing hooks.\nThis results in less brittle tests that may exercise more production code, instead of just verifying\nspecific calls against mocks.\n\nExamples:\n- In instrumentation tests, a temporary folder is used to store the user's preferences, which is\n  wiped after each test.\n  This allows using the real `DataStore` and exercising all related code, instead of mocking the \n  flow of data updates.\n\n- There are `Test` implementations of each repository, which implement the normal, full repository\n  interface and also provide test-only hooks.\n  `ViewModel` tests use these `Test` repositories, and thus can use the test-only hooks to\n  manipulate the state of the `Test` repository and verify the resulting behavior, instead of\n  checking that specific repository methods were called.\n\nTo run the tests execute the following gradle tasks: \n\n- `testDemoDebug` run all local tests against the `demoDebug` variant. Screenshot tests will fail\n(see below for explanation). To avoid this, run `recordRoborazziDemoDebug` prior to running unit tests.\n- `connectedDemoDebugAndroidTest` run all instrumented tests against the `demoDebug` variant. \n\n> [!NOTE]\n> You should not run `./gradlew test` or `./gradlew connectedAndroidTest` as this will execute \ntests against _all_ build variants which is both unnecessary and will result in failures as only the\n`demoDebug` variant is supported. No other variants have any tests (although this might change in future). \n\n## Screenshot tests\nA screenshot test takes a screenshot of a screen or a UI component within the app, and compares it \nwith a previously recorded screenshot which is known to be rendered correctly. \n\nFor example, Now in Android has [screenshot tests](https://github.com/android/nowinandroid/blob/main/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt)\nto verify that the navigation is displayed correctly on different screen sizes \n([known correct screenshots](https://github.com/android/nowinandroid/tree/main/app/src/testDemo/screenshots)). \n\nNow In Android uses [Roborazzi](https://github.com/takahirom/roborazzi) to run screenshot tests\nof certain screens and UI components. When working with screenshot tests the following gradle tasks are useful:\n\n- `verifyRoborazziDemoDebug` run all screenshot tests, verifying the screenshots against the known\ncorrect screenshots.\n- `recordRoborazziDemoDebug` record new \"known correct\" screenshots. Use this command when you have\nmade changes to the UI and manually verified that they are rendered correctly. Screenshots will be\nstored in `modulename/src/test/screenshots`.\n- `compareRoborazziDemoDebug` create comparison images between failed tests and the known correct\nimages. These can also be found in `modulename/src/test/screenshots`. \n\n> [!NOTE]\n> **Note on failing screenshot tests**   \n> The known correct screenshots stored in this repository are recorded on CI using Linux. Other\nplatforms may (and probably will) generate slightly different images, making the screenshot tests fail. \nWhen working on a non-Linux platform, a workaround to this is to run `recordRoborazziDemoDebug` on the\n`main` branch before starting work. After making changes, `verifyRoborazziDemoDebug` will identify only\nlegitimate changes. \n\nFor more information about screenshot testing \n[check out this talk](https://www.droidcon.com/2023/11/15/easy-screenshot-testing-with-compose/).\n\n# UI\nThe app was designed using [Material 3 guidelines](https://m3.material.io/). Learn more about the design process and \nobtain the design files in the [Now in Android Material 3 Case Study](https://goo.gle/nia-figma) (design assets [also available as a PDF](docs/Now-In-Android-Design-File.pdf)).\n\nThe Screens and UI elements are built entirely using [Jetpack Compose](https://developer.android.com/jetpack/compose). \n\nThe app has two themes: \n\n- Dynamic color - uses colors based on the [user's current color theme](https://material.io/blog/announcing-material-you) (if supported)\n- Default theme - uses predefined colors when dynamic color is not supported\n\nEach theme also supports dark mode. \n\nThe app uses adaptive layouts to\n[support different screen sizes](https://developer.android.com/guide/topics/large-screens/support-different-screen-sizes).\n\nFind out more about the [UI architecture here](docs/ArchitectureLearningJourney.md#ui-layer).\n\n# Performance\n\n## Benchmarks\n\nFind all tests written using [`Macrobenchmark`](https://developer.android.com/topic/performance/benchmarking/macrobenchmark-overview)\nin the `benchmarks` module. This module also contains the test to generate the Baseline profile.\n\n## Baseline profiles\n\nThe baseline profile for this app is located at [`app/src/main/baseline-prof.txt`](app/src/main/baseline-prof.txt).\nIt contains rules that enable AOT compilation of the critical user path taken during app launch.\nFor more information on baseline profiles, read [this document](https://developer.android.com/studio/profile/baselineprofiles).\n\n> [!NOTE]\n> The baseline profile needs to be re-generated for release builds that touch code which changes app startup.\n\nTo generate the baseline profile, select the `benchmark` build variant and run the\n`BaselineProfileGenerator` benchmark test on an AOSP Android Emulator.\nThen copy the resulting baseline profile from the emulator to [`app/src/main/baseline-prof.txt`](app/src/main/baseline-prof.txt).\n\n## Compose compiler metrics\n\nRun the following command to get and analyze compose compiler metrics:\n\n```bash\n./gradlew assembleRelease -PenableComposeCompilerMetrics=true -PenableComposeCompilerReports=true\n```\n\nThe reports files will be added to [build/compose-reports](build/compose-reports). The metrics files will also be \nadded to [build/compose-metrics](build/compose-metrics).\n\nFor more information on Compose compiler metrics, see [this blog post](https://medium.com/androiddevelopers/jetpack-compose-stability-explained-79c10db270c8).\n\n# License\n\n**Now in Android** is distributed under the terms of the Apache License (Version 2.0). See the\n[license](LICENSE) for more information.\n"
  },
  {
    "path": "app/.gitignore",
    "content": "/build"
  },
  {
    "path": "app/README.md",
    "content": "# `:app`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :feature\n    direction TB\n    subgraph :feature:settings\n      direction TB\n      :feature:settings:impl[impl]:::android-library\n    end\n    subgraph :feature:foryou\n      direction TB\n      :feature:foryou:api[api]:::android-library\n      :feature:foryou:impl[impl]:::android-library\n    end\n    subgraph :feature:bookmarks\n      direction TB\n      :feature:bookmarks:api[api]:::android-library\n      :feature:bookmarks:impl[impl]:::android-library\n    end\n    subgraph :feature:search\n      direction TB\n      :feature:search:api[api]:::android-library\n      :feature:search:impl[impl]:::android-library\n    end\n    subgraph :feature:interests\n      direction TB\n      :feature:interests:api[api]:::android-library\n      :feature:interests:impl[impl]:::android-library\n    end\n    subgraph :feature:topic\n      direction TB\n      :feature:topic:api[api]:::android-library\n      :feature:topic:impl[impl]:::android-library\n    end\n  end\n  subgraph :sync\n    direction TB\n    :sync:work[work]:::android-library\n  end\n  subgraph :core\n    direction TB\n    :core:analytics[analytics]:::android-library\n    :core:common[common]:::jvm-library\n    :core:data[data]:::android-library\n    :core:database[database]:::android-library\n    :core:datastore[datastore]:::android-library\n    :core:datastore-proto[datastore-proto]:::jvm-library\n    :core:designsystem[designsystem]:::android-library\n    :core:domain[domain]:::android-library\n    :core:model[model]:::jvm-library\n    :core:navigation[navigation]:::android-library\n    :core:network[network]:::android-library\n    :core:notifications[notifications]:::android-library\n    :core:ui[ui]:::android-library\n  end\n  :benchmarks[benchmarks]:::android-test\n  :app[app]:::android-application\n\n  :app -.->|baselineProfile| :benchmarks\n  :app -.-> :core:analytics\n  :app -.-> :core:common\n  :app -.-> :core:data\n  :app -.-> :core:designsystem\n  :app -.-> :core:model\n  :app -.-> :core:ui\n  :app -.-> :feature:bookmarks:api\n  :app -.-> :feature:bookmarks:impl\n  :app -.-> :feature:foryou:api\n  :app -.-> :feature:foryou:impl\n  :app -.-> :feature:interests:api\n  :app -.-> :feature:interests:impl\n  :app -.-> :feature:search:api\n  :app -.-> :feature:search:impl\n  :app -.-> :feature:settings:impl\n  :app -.-> :feature:topic:api\n  :app -.-> :feature:topic:impl\n  :app -.-> :sync:work\n  :benchmarks -.->|testedApks| :app\n  :core:data -.-> :core:analytics\n  :core:data --> :core:common\n  :core:data --> :core:database\n  :core:data --> :core:datastore\n  :core:data --> :core:network\n  :core:data -.-> :core:notifications\n  :core:database --> :core:model\n  :core:datastore -.-> :core:common\n  :core:datastore --> :core:datastore-proto\n  :core:datastore --> :core:model\n  :core:domain --> :core:data\n  :core:domain --> :core:model\n  :core:network --> :core:common\n  :core:network --> :core:model\n  :core:notifications -.-> :core:common\n  :core:notifications --> :core:model\n  :core:ui --> :core:analytics\n  :core:ui --> :core:designsystem\n  :core:ui --> :core:model\n  :feature:bookmarks:api --> :core:navigation\n  :feature:bookmarks:impl -.-> :core:data\n  :feature:bookmarks:impl -.-> :core:designsystem\n  :feature:bookmarks:impl -.-> :core:ui\n  :feature:bookmarks:impl -.-> :feature:bookmarks:api\n  :feature:bookmarks:impl -.-> :feature:topic:api\n  :feature:foryou:api --> :core:navigation\n  :feature:foryou:impl -.-> :core:designsystem\n  :feature:foryou:impl -.-> :core:domain\n  :feature:foryou:impl -.-> :core:notifications\n  :feature:foryou:impl -.-> :core:ui\n  :feature:foryou:impl -.-> :feature:foryou:api\n  :feature:foryou:impl -.-> :feature:topic:api\n  :feature:interests:api --> :core:navigation\n  :feature:interests:impl -.-> :core:designsystem\n  :feature:interests:impl -.-> :core:domain\n  :feature:interests:impl -.-> :core:ui\n  :feature:interests:impl -.-> :feature:interests:api\n  :feature:interests:impl -.-> :feature:topic:api\n  :feature:search:api -.-> :core:domain\n  :feature:search:api --> :core:navigation\n  :feature:search:impl -.-> :core:designsystem\n  :feature:search:impl -.-> :core:domain\n  :feature:search:impl -.-> :core:ui\n  :feature:search:impl -.-> :feature:interests:api\n  :feature:search:impl -.-> :feature:search:api\n  :feature:search:impl -.-> :feature:topic:api\n  :feature:settings:impl -.-> :core:data\n  :feature:settings:impl -.-> :core:designsystem\n  :feature:settings:impl -.-> :core:ui\n  :feature:topic:api -.-> :core:designsystem\n  :feature:topic:api --> :core:navigation\n  :feature:topic:api -.-> :core:ui\n  :feature:topic:impl -.-> :core:data\n  :feature:topic:impl -.-> :core:designsystem\n  :feature:topic:impl -.-> :core:ui\n  :feature:topic:impl -.-> :feature:topic:api\n  :sync:work -.-> :core:analytics\n  :sync:work -.-> :core:data\n  :sync:work -.-> :core:notifications\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "app/benchmark-rules.pro",
    "content": "# Proguard rules for the `benchmark` build type.\n#\n# Obsfuscation must be disabled for the build variant that generates Baseline Profile, otherwise\n# wrong symbols would be generated. The generated Baseline Profile will be properly applied when generated\n# without obfuscation and your app is being obfuscated.\n-dontobfuscate\n\n# Please add these rules to your existing keep rules in order to suppress warnings.\n# This is generated automatically by the Android Gradle plugin.\n-dontwarn org.bouncycastle.jsse.BCSSLParameters\n-dontwarn org.bouncycastle.jsse.BCSSLSocket\n-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider\n-dontwarn org.conscrypt.Conscrypt$Version\n-dontwarn org.conscrypt.Conscrypt\n-dontwarn org.conscrypt.ConscryptHostnameVerifier\n-dontwarn org.openjsse.javax.net.ssl.SSLParameters\n-dontwarn org.openjsse.javax.net.ssl.SSLSocket\n-dontwarn org.openjsse.net.ssl.OpenJSSE"
  },
  {
    "path": "app/build.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nimport com.google.samples.apps.nowinandroid.NiaBuildType\n\nplugins {\n    alias(libs.plugins.nowinandroid.android.application)\n    alias(libs.plugins.nowinandroid.android.application.compose)\n    alias(libs.plugins.nowinandroid.android.application.flavors)\n    alias(libs.plugins.nowinandroid.android.application.jacoco)\n    alias(libs.plugins.nowinandroid.android.application.firebase)\n    alias(libs.plugins.nowinandroid.hilt)\n    alias(libs.plugins.google.osslicenses)\n    alias(libs.plugins.baselineprofile)\n    alias(libs.plugins.roborazzi)\n    alias(libs.plugins.kotlin.serialization)\n}\n\nandroid {\n    defaultConfig {\n        applicationId = \"com.google.samples.apps.nowinandroid\"\n        versionCode = 8\n        versionName = \"0.1.2\" // X.Y.Z; X = Major, Y = minor, Z = Patch level\n\n        // Custom test runner to set up Hilt dependency graph\n        testInstrumentationRunner = \"com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner\"\n    }\n\n    buildTypes {\n        debug {\n            applicationIdSuffix = NiaBuildType.DEBUG.applicationIdSuffix\n        }\n        release {\n            isMinifyEnabled = providers.gradleProperty(\"minifyWithR8\")\n                .map(String::toBooleanStrict).getOrElse(true)\n            applicationIdSuffix = NiaBuildType.RELEASE.applicationIdSuffix\n            proguardFiles(getDefaultProguardFile(\"proguard-android-optimize.txt\"),\n                          \"proguard-rules.pro\")\n\n            // To publish on the Play store a private signing key is required, but to allow anyone\n            // who clones the code to sign and run the release variant, use the debug signing key.\n            // TODO: Abstract the signing configuration to a separate file to avoid hardcoding this.\n            signingConfig = signingConfigs.named(\"debug\").get()\n            // Ensure Baseline Profile is fresh for release builds.\n            baselineProfile.automaticGenerationDuringBuild = true\n        }\n    }\n\n    packaging {\n        resources {\n            excludes.add(\"/META-INF/{AL2.0,LGPL2.1}\")\n        }\n    }\n    testOptions.unitTests.isIncludeAndroidResources = true\n    namespace = \"com.google.samples.apps.nowinandroid\"\n}\n\ndependencies {\n    implementation(projects.feature.interests.api)\n    implementation(projects.feature.interests.impl)\n    implementation(projects.feature.foryou.api)\n    implementation(projects.feature.foryou.impl)\n    implementation(projects.feature.bookmarks.api)\n    implementation(projects.feature.bookmarks.impl)\n    implementation(projects.feature.topic.api)\n    implementation(projects.feature.topic.impl)\n    implementation(projects.feature.search.api)\n    implementation(projects.feature.search.impl)\n    implementation(projects.feature.settings.impl)\n\n    implementation(projects.core.common)\n    implementation(projects.core.ui)\n    implementation(projects.core.designsystem)\n    implementation(projects.core.data)\n    implementation(projects.core.model)\n    implementation(projects.core.analytics)\n    implementation(projects.sync.work)\n\n    implementation(libs.androidx.activity.compose)\n    implementation(libs.androidx.compose.material3)\n    implementation(libs.androidx.navigation3.ui)\n    implementation(libs.androidx.compose.material3.adaptive)\n    implementation(libs.androidx.compose.material3.adaptive.layout)\n    implementation(libs.androidx.compose.material3.adaptive.navigation)\n    implementation(libs.androidx.compose.material3.adaptive.navigation3)\n    implementation(libs.androidx.compose.material3.windowSizeClass)\n    implementation(libs.androidx.compose.runtime.tracing)\n    implementation(libs.androidx.core.ktx)\n    implementation(libs.androidx.core.splashscreen)\n    implementation(libs.androidx.lifecycle.runtimeCompose)\n    implementation(libs.androidx.lifecycle.viewModel.navigation3)\n    implementation(libs.androidx.profileinstaller)\n    implementation(libs.androidx.tracing.ktx)\n    implementation(libs.androidx.window.core)\n    implementation(libs.kotlinx.coroutines.guava)\n    implementation(libs.coil.kt)\n    implementation(libs.kotlinx.serialization.json)\n\n    ksp(libs.hilt.compiler)\n\n    debugImplementation(libs.androidx.compose.ui.testManifest)\n    debugImplementation(projects.uiTestHiltManifest)\n\n    kspTest(libs.hilt.compiler)\n\n    testImplementation(projects.core.dataTest)\n    testImplementation(projects.core.datastoreTest)\n    testImplementation(libs.hilt.android.testing)\n    testImplementation(projects.sync.syncTest)\n    testImplementation(libs.kotlin.test)\n\n    testDemoImplementation(libs.androidx.navigation.testing)\n    testDemoImplementation(libs.robolectric)\n    testDemoImplementation(libs.roborazzi)\n    testDemoImplementation(projects.core.screenshotTesting)\n    testDemoImplementation(projects.core.testing)\n\n    androidTestImplementation(projects.core.testing)\n    androidTestImplementation(projects.core.dataTest)\n    androidTestImplementation(projects.core.datastoreTest)\n    androidTestImplementation(libs.androidx.test.espresso.core)\n    androidTestImplementation(libs.androidx.compose.ui.test)\n    androidTestImplementation(libs.hilt.android.testing)\n    androidTestImplementation(libs.kotlin.test)\n\n    baselineProfile(projects.benchmarks)\n}\n\nbaselineProfile {\n    // Don't build on every iteration of a full assemble.\n    // Instead enable generation directly for the release build variant.\n    automaticGenerationDuringBuild = false\n\n    // Make use of Dex Layout Optimizations via Startup Profiles\n    dexLayoutOptimization = true\n}\n\ndependencyGuard {\n    configuration(\"prodReleaseRuntimeClasspath\")\n}\n"
  },
  {
    "path": "app/dependencies/prodReleaseRuntimeClasspath.txt",
    "content": "androidx.activity:activity-compose:1.12.0\nandroidx.activity:activity-ktx:1.12.0\nandroidx.activity:activity:1.12.0\nandroidx.annotation:annotation-experimental:1.5.1\nandroidx.annotation:annotation-jvm:1.9.1\nandroidx.annotation:annotation:1.9.1\nandroidx.appcompat:appcompat-resources:1.7.0\nandroidx.appcompat:appcompat:1.7.0\nandroidx.arch.core:core-common:2.2.0\nandroidx.arch.core:core-runtime:2.2.0\nandroidx.autofill:autofill:1.0.0\nandroidx.browser:browser:1.8.0\nandroidx.collection:collection-jvm:1.5.0\nandroidx.collection:collection-ktx:1.5.0\nandroidx.collection:collection:1.5.0\nandroidx.compose.animation:animation-android:1.10.0-beta02\nandroidx.compose.animation:animation-core-android:1.10.0-beta02\nandroidx.compose.animation:animation-core:1.10.0-beta02\nandroidx.compose.animation:animation:1.10.0-beta02\nandroidx.compose.foundation:foundation-android:1.10.0-beta02\nandroidx.compose.foundation:foundation-layout-android:1.10.0-beta02\nandroidx.compose.foundation:foundation-layout:1.10.0-beta02\nandroidx.compose.foundation:foundation:1.10.0-beta02\nandroidx.compose.material3.adaptive:adaptive-android:1.3.0-alpha04\nandroidx.compose.material3.adaptive:adaptive-layout-android:1.3.0-alpha04\nandroidx.compose.material3.adaptive:adaptive-layout:1.3.0-alpha04\nandroidx.compose.material3.adaptive:adaptive-navigation-android:1.3.0-alpha04\nandroidx.compose.material3.adaptive:adaptive-navigation3-android:1.3.0-alpha04\nandroidx.compose.material3.adaptive:adaptive-navigation3:1.3.0-alpha04\nandroidx.compose.material3.adaptive:adaptive-navigation:1.3.0-alpha04\nandroidx.compose.material3.adaptive:adaptive:1.3.0-alpha04\nandroidx.compose.material3:material3-adaptive-navigation-suite-android:1.5.0-alpha04\nandroidx.compose.material3:material3-adaptive-navigation-suite:1.5.0-alpha04\nandroidx.compose.material3:material3-android:1.5.0-alpha04\nandroidx.compose.material3:material3-window-size-class-android:1.5.0-alpha04\nandroidx.compose.material3:material3-window-size-class:1.5.0-alpha04\nandroidx.compose.material3:material3:1.5.0-alpha04\nandroidx.compose.material:material-icons-core-android:1.7.8\nandroidx.compose.material:material-icons-core:1.7.8\nandroidx.compose.material:material-icons-extended-android:1.7.8\nandroidx.compose.material:material-icons-extended:1.7.8\nandroidx.compose.material:material-ripple-android:1.10.0-alpha04\nandroidx.compose.material:material-ripple:1.10.0-alpha04\nandroidx.compose.runtime:runtime-android:1.10.0-beta02\nandroidx.compose.runtime:runtime-annotation-android:1.10.0-beta02\nandroidx.compose.runtime:runtime-annotation:1.10.0-beta02\nandroidx.compose.runtime:runtime-retain-android:1.10.0-beta02\nandroidx.compose.runtime:runtime-retain:1.10.0-beta02\nandroidx.compose.runtime:runtime-saveable-android:1.10.0-beta02\nandroidx.compose.runtime:runtime-saveable:1.10.0-beta02\nandroidx.compose.runtime:runtime-tracing:1.10.0-beta02\nandroidx.compose.runtime:runtime:1.10.0-beta02\nandroidx.compose.ui:ui-android:1.10.0-beta02\nandroidx.compose.ui:ui-geometry-android:1.10.0-beta02\nandroidx.compose.ui:ui-geometry:1.10.0-beta02\nandroidx.compose.ui:ui-graphics-android:1.10.0-beta02\nandroidx.compose.ui:ui-graphics:1.10.0-beta02\nandroidx.compose.ui:ui-text-android:1.10.0-beta02\nandroidx.compose.ui:ui-text:1.10.0-beta02\nandroidx.compose.ui:ui-tooling-preview-android:1.10.0-beta02\nandroidx.compose.ui:ui-tooling-preview:1.10.0-beta02\nandroidx.compose.ui:ui-unit-android:1.10.0-beta02\nandroidx.compose.ui:ui-unit:1.10.0-beta02\nandroidx.compose.ui:ui-util-android:1.10.0-beta02\nandroidx.compose.ui:ui-util:1.10.0-beta02\nandroidx.compose.ui:ui:1.10.0-beta02\nandroidx.compose:compose-bom-alpha:2025.09.01\nandroidx.concurrent:concurrent-futures-ktx:1.1.0\nandroidx.concurrent:concurrent-futures:1.1.0\nandroidx.core:core-ktx:1.16.0\nandroidx.core:core-splashscreen:1.0.1\nandroidx.core:core-viewtree:1.0.0\nandroidx.core:core:1.16.0\nandroidx.cursoradapter:cursoradapter:1.0.0\nandroidx.customview:customview-poolingcontainer:1.0.0\nandroidx.customview:customview:1.0.0\nandroidx.datastore:datastore-android:1.2.0\nandroidx.datastore:datastore-core-android:1.2.0\nandroidx.datastore:datastore-core-okio-jvm:1.2.0\nandroidx.datastore:datastore-core-okio:1.2.0\nandroidx.datastore:datastore-core:1.2.0\nandroidx.datastore:datastore-preferences-android:1.2.0\nandroidx.datastore:datastore-preferences-core-android:1.2.0\nandroidx.datastore:datastore-preferences-core:1.2.0\nandroidx.datastore:datastore-preferences-external-protobuf:1.2.0\nandroidx.datastore:datastore-preferences-proto:1.2.0\nandroidx.datastore:datastore-preferences:1.2.0\nandroidx.datastore:datastore:1.2.0\nandroidx.documentfile:documentfile:1.0.0\nandroidx.drawerlayout:drawerlayout:1.0.0\nandroidx.dynamicanimation:dynamicanimation:1.0.0\nandroidx.emoji2:emoji2-views-helper:1.4.0\nandroidx.emoji2:emoji2:1.4.0\nandroidx.exifinterface:exifinterface:1.3.7\nandroidx.fragment:fragment:1.5.4\nandroidx.graphics:graphics-path:1.0.1\nandroidx.graphics:graphics-shapes-android:1.0.1\nandroidx.graphics:graphics-shapes:1.0.1\nandroidx.hilt:hilt-common:1.2.0\nandroidx.hilt:hilt-lifecycle-viewmodel-compose:1.3.0-alpha02\nandroidx.hilt:hilt-lifecycle-viewmodel:1.3.0-alpha02\nandroidx.hilt:hilt-work:1.2.0\nandroidx.interpolator:interpolator:1.0.0\nandroidx.legacy:legacy-support-core-utils:1.0.0\nandroidx.lifecycle:lifecycle-common-java8:2.10.0\nandroidx.lifecycle:lifecycle-common-jvm:2.10.0\nandroidx.lifecycle:lifecycle-common:2.10.0\nandroidx.lifecycle:lifecycle-livedata-core-ktx:2.10.0\nandroidx.lifecycle:lifecycle-livedata-core:2.10.0\nandroidx.lifecycle:lifecycle-livedata:2.10.0\nandroidx.lifecycle:lifecycle-process:2.10.0\nandroidx.lifecycle:lifecycle-runtime-android:2.10.0\nandroidx.lifecycle:lifecycle-runtime-compose-android:2.10.0\nandroidx.lifecycle:lifecycle-runtime-compose:2.10.0\nandroidx.lifecycle:lifecycle-runtime-ktx-android:2.10.0\nandroidx.lifecycle:lifecycle-runtime-ktx:2.10.0\nandroidx.lifecycle:lifecycle-runtime:2.10.0\nandroidx.lifecycle:lifecycle-service:2.10.0\nandroidx.lifecycle:lifecycle-viewmodel-android:2.10.0\nandroidx.lifecycle:lifecycle-viewmodel-compose-android:2.10.0\nandroidx.lifecycle:lifecycle-viewmodel-compose:2.10.0\nandroidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0\nandroidx.lifecycle:lifecycle-viewmodel-navigation3-android:2.10.0\nandroidx.lifecycle:lifecycle-viewmodel-navigation3:2.10.0\nandroidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.10.0\nandroidx.lifecycle:lifecycle-viewmodel-savedstate:2.10.0\nandroidx.lifecycle:lifecycle-viewmodel:2.10.0\nandroidx.loader:loader:1.0.0\nandroidx.localbroadcastmanager:localbroadcastmanager:1.0.0\nandroidx.metrics:metrics-performance:1.0.0-beta01\nandroidx.navigation3:navigation3-runtime-android:1.0.0\nandroidx.navigation3:navigation3-runtime:1.0.0\nandroidx.navigation3:navigation3-ui-android:1.0.0\nandroidx.navigation3:navigation3-ui:1.0.0\nandroidx.navigationevent:navigationevent-android:1.0.0\nandroidx.navigationevent:navigationevent-compose-android:1.0.0\nandroidx.navigationevent:navigationevent-compose:1.0.0\nandroidx.navigationevent:navigationevent:1.0.0\nandroidx.print:print:1.0.0\nandroidx.privacysandbox.ads:ads-adservices-java:1.0.0-beta05\nandroidx.privacysandbox.ads:ads-adservices:1.0.0-beta05\nandroidx.profileinstaller:profileinstaller:1.4.1\nandroidx.resourceinspection:resourceinspection-annotation:1.0.1\nandroidx.room:room-common-jvm:2.8.3\nandroidx.room:room-common:2.8.3\nandroidx.room:room-ktx:2.8.3\nandroidx.room:room-runtime-android:2.8.3\nandroidx.room:room-runtime:2.8.3\nandroidx.savedstate:savedstate-android:1.4.0\nandroidx.savedstate:savedstate-compose-android:1.4.0\nandroidx.savedstate:savedstate-compose:1.4.0\nandroidx.savedstate:savedstate-ktx:1.4.0\nandroidx.savedstate:savedstate:1.4.0\nandroidx.sqlite:sqlite-android:2.6.1\nandroidx.sqlite:sqlite-framework-android:2.6.1\nandroidx.sqlite:sqlite-framework:2.6.1\nandroidx.sqlite:sqlite:2.6.1\nandroidx.startup:startup-runtime:1.1.1\nandroidx.tracing:tracing-ktx:1.3.0-alpha02\nandroidx.tracing:tracing-perfetto:1.0.0\nandroidx.tracing:tracing:1.3.0-alpha02\nandroidx.transition:transition:1.6.0\nandroidx.vectordrawable:vectordrawable-animated:1.1.0\nandroidx.vectordrawable:vectordrawable:1.1.0\nandroidx.versionedparcelable:versionedparcelable:1.1.1\nandroidx.viewpager:viewpager:1.0.0\nandroidx.window:window-core-android:1.5.0\nandroidx.window:window-core:1.5.0\nandroidx.window:window:1.5.0\nandroidx.work:work-runtime-ktx:2.10.0\nandroidx.work:work-runtime:2.10.0\ncom.caverock:androidsvg-aar:1.4\ncom.google.accompanist:accompanist-drawablepainter:0.32.0\ncom.google.accompanist:accompanist-permissions:0.37.0\ncom.google.android.datatransport:transport-api:3.2.0\ncom.google.android.datatransport:transport-backend-cct:3.3.0\ncom.google.android.datatransport:transport-runtime:3.3.0\ncom.google.android.gms:play-services-ads-identifier:18.0.0\ncom.google.android.gms:play-services-base:18.5.0\ncom.google.android.gms:play-services-basement:18.4.0\ncom.google.android.gms:play-services-cloud-messaging:17.2.0\ncom.google.android.gms:play-services-measurement-api:22.1.2\ncom.google.android.gms:play-services-measurement-base:22.1.2\ncom.google.android.gms:play-services-measurement-impl:22.1.2\ncom.google.android.gms:play-services-measurement-sdk-api:22.1.2\ncom.google.android.gms:play-services-measurement-sdk:22.1.2\ncom.google.android.gms:play-services-measurement:22.1.2\ncom.google.android.gms:play-services-oss-licenses:17.1.0\ncom.google.android.gms:play-services-stats:17.0.2\ncom.google.android.gms:play-services-tasks:18.2.0\ncom.google.code.findbugs:jsr305:3.0.2\ncom.google.dagger:dagger-lint-aar:2.59\ncom.google.dagger:dagger:2.59\ncom.google.dagger:hilt-android:2.59\ncom.google.dagger:hilt-core:2.59\ncom.google.errorprone:error_prone_annotations:2.26.0\ncom.google.firebase:firebase-abt:21.1.1\ncom.google.firebase:firebase-analytics:22.1.2\ncom.google.firebase:firebase-annotations:16.2.0\ncom.google.firebase:firebase-bom:33.7.0\ncom.google.firebase:firebase-common-ktx:21.0.0\ncom.google.firebase:firebase-common:21.0.0\ncom.google.firebase:firebase-components:18.0.0\ncom.google.firebase:firebase-config-interop:16.0.1\ncom.google.firebase:firebase-config:22.0.1\ncom.google.firebase:firebase-crashlytics:19.3.0\ncom.google.firebase:firebase-datatransport:19.0.0\ncom.google.firebase:firebase-encoders-json:18.0.1\ncom.google.firebase:firebase-encoders-proto:16.0.0\ncom.google.firebase:firebase-encoders:17.0.0\ncom.google.firebase:firebase-iid-interop:17.1.0\ncom.google.firebase:firebase-installations-interop:17.2.0\ncom.google.firebase:firebase-installations:18.0.0\ncom.google.firebase:firebase-measurement-connector:20.0.1\ncom.google.firebase:firebase-messaging:24.1.0\ncom.google.firebase:firebase-perf:21.0.3\ncom.google.firebase:firebase-sessions:2.0.7\ncom.google.guava:failureaccess:1.0.1\ncom.google.guava:guava:31.1-android\ncom.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava\ncom.google.j2objc:j2objc-annotations:1.3\ncom.google.protobuf:protobuf-javalite:4.29.2\ncom.google.protobuf:protobuf-kotlin-lite:4.29.2\ncom.squareup.okhttp3:logging-interceptor:4.12.0\ncom.squareup.okhttp3:okhttp:4.12.0\ncom.squareup.okio:okio-jvm:3.9.1\ncom.squareup.okio:okio:3.9.1\ncom.squareup.retrofit2:converter-kotlinx-serialization:2.11.0\ncom.squareup.retrofit2:retrofit:2.11.0\nio.coil-kt:coil-base:2.7.0\nio.coil-kt:coil-compose-base:2.7.0\nio.coil-kt:coil-compose:2.7.0\nio.coil-kt:coil-svg:2.7.0\nio.coil-kt:coil:2.7.0\njakarta.inject:jakarta.inject-api:2.0.1\njavax.inject:javax.inject:1\norg.checkerframework:checker-qual:3.12.0\norg.jetbrains.kotlin:kotlin-stdlib-common:2.3.0\norg.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0\norg.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0\norg.jetbrains.kotlin:kotlin-stdlib:2.3.0\norg.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1\norg.jetbrains.kotlinx:kotlinx-coroutines-bom:1.10.1\norg.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.10.1\norg.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1\norg.jetbrains.kotlinx:kotlinx-coroutines-guava:1.10.1\norg.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.10.1\norg.jetbrains.kotlinx:kotlinx-datetime-jvm:0.6.1\norg.jetbrains.kotlinx:kotlinx-datetime:0.6.1\norg.jetbrains.kotlinx:kotlinx-serialization-bom:1.8.0\norg.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.8.0\norg.jetbrains.kotlinx:kotlinx-serialization-core:1.8.0\norg.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.8.0\norg.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0\norg.jetbrains:annotations:23.0.0\norg.jspecify:jspecify:1.0.0\n"
  },
  {
    "path": "app/google-services.json",
    "content": "{\n  \"project_info\": {\n    \"project_number\": \"YourProjectId\",\n    \"project_id\": \"abc\",\n    \"storage_bucket\": \"abc\"\n  },\n  \"client\": [\n    {\n      \"client_info\": {\n        \"mobilesdk_app_id\": \"Your:App:Id\",\n        \"android_client_info\": {\n          \"package_name\": \"com.google.samples.apps.nowinandroid\"\n        }\n      },\n      \"oauth_client\": [],\n      \"api_key\": [\n        {\n          \"current_key\": \"APlaceholderAPIKeyWith-ThirtyNineCharsX\"\n        }\n      ],\n      \"services\": {\n        \"appinvite_service\": {\n          \"other_platform_oauth_client\": []\n        }\n      }\n    },\n    {\n      \"client_info\": {\n        \"mobilesdk_app_id\": \"Your:App:Id\",\n        \"android_client_info\": {\n          \"package_name\": \"com.google.samples.apps.nowinandroid.demo.debug\"\n        }\n      },\n      \"oauth_client\": [],\n      \"api_key\": [\n        {\n          \"current_key\": \"APlaceholderAPIKeyWith-ThirtyNineCharsX\"\n        }\n      ],\n      \"services\": {\n        \"appinvite_service\": {\n          \"other_platform_oauth_client\": []\n        }\n      }\n    },\n    {\n      \"client_info\": {\n        \"mobilesdk_app_id\": \"Your:App:Id\",\n        \"android_client_info\": {\n          \"package_name\": \"com.google.samples.apps.nowinandroid.demo.benchmark\"\n        }\n      },\n      \"oauth_client\": [],\n      \"api_key\": [\n        {\n          \"current_key\": \"APlaceholderAPIKeyWith-ThirtyNineCharsX\"\n        }\n      ],\n      \"services\": {\n        \"appinvite_service\": {\n          \"other_platform_oauth_client\": []\n        }\n      }\n    },\n    {\n      \"client_info\": {\n        \"mobilesdk_app_id\": \"Your:App:Id\",\n        \"android_client_info\": {\n          \"package_name\": \"com.google.samples.apps.nowinandroid.benchmark\"\n        }\n      },\n      \"oauth_client\": [],\n      \"api_key\": [\n        {\n          \"current_key\": \"APlaceholderAPIKeyWith-ThirtyNineCharsX\"\n        }\n      ],\n      \"services\": {\n        \"appinvite_service\": {\n          \"other_platform_oauth_client\": []\n        }\n      }\n    },\n    {\n      \"client_info\": {\n        \"mobilesdk_app_id\": \"Your:App:Id\",\n        \"android_client_info\": {\n          \"package_name\": \"com.google.samples.apps.nowinandroid.debug\"\n        }\n      },\n      \"oauth_client\": [],\n      \"api_key\": [\n        {\n          \"current_key\": \"APlaceholderAPIKeyWith-ThirtyNineCharsX\"\n        }\n      ],\n      \"services\": {\n        \"appinvite_service\": {\n          \"other_platform_oauth_client\": []\n        }\n      }\n    },\n    {\n      \"client_info\": {\n        \"mobilesdk_app_id\": \"Your:App:Id\",\n        \"android_client_info\": {\n          \"package_name\": \"com.google.samples.apps.nowinandroid.demo\"\n        }\n      },\n      \"oauth_client\": [],\n      \"api_key\": [\n        {\n          \"current_key\": \"APlaceholderAPIKeyWith-ThirtyNineCharsX\"\n        }\n      ],\n      \"services\": {\n        \"appinvite_service\": {\n          \"other_platform_oauth_client\": []\n        }\n      }\n    }\n  ],\n\n  \"configuration_version\": \"1\"\n}"
  },
  {
    "path": "app/prodRelease-badging.txt",
    "content": "package: name='com.google.samples.apps.nowinandroid' versionCode='8' versionName='0.1.2' platformBuildVersionName='16' platformBuildVersionCode='36' compileSdkVersion='36' compileSdkVersionCodename='16'\nminSdkVersion:'23'\ntargetSdkVersion:'36'\nuses-permission: name='android.permission.INTERNET'\nuses-permission: name='android.permission.ACCESS_NETWORK_STATE'\nuses-permission: name='android.permission.POST_NOTIFICATIONS'\nuses-permission: name='android.permission.WAKE_LOCK'\nuses-permission: name='com.google.android.c2dm.permission.RECEIVE'\nuses-permission: name='com.google.android.finsky.permission.BIND_GET_INSTALL_REFERRER_SERVICE'\nuses-permission: name='android.permission.RECEIVE_BOOT_COMPLETED'\nuses-permission: name='android.permission.FOREGROUND_SERVICE'\nuses-permission: name='com.google.samples.apps.nowinandroid.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION'\napplication-label:'Now in Android'\napplication-label-af:'Now in Android'\napplication-label-am:'Now in Android'\napplication-label-ar:'Now in Android'\napplication-label-as:'Now in Android'\napplication-label-az:'Now in Android'\napplication-label-be:'Now in Android'\napplication-label-bg:'Now in Android'\napplication-label-bn:'Now in Android'\napplication-label-bs:'Now in Android'\napplication-label-ca:'Now in Android'\napplication-label-cs:'Now in Android'\napplication-label-da:'Now in Android'\napplication-label-de:'Now in Android'\napplication-label-el:'Now in Android'\napplication-label-en-AU:'Now in Android'\napplication-label-en-CA:'Now in Android'\napplication-label-en-GB:'Now in Android'\napplication-label-en-IN:'Now in Android'\napplication-label-en-XC:'Now in Android'\napplication-label-es:'Now in Android'\napplication-label-es-US:'Now in Android'\napplication-label-et:'Now in Android'\napplication-label-eu:'Now in Android'\napplication-label-fa:'Now in Android'\napplication-label-fi:'Now in Android'\napplication-label-fr:'Now in Android'\napplication-label-fr-CA:'Now in Android'\napplication-label-gl:'Now in Android'\napplication-label-gu:'Now in Android'\napplication-label-hi:'Now in Android'\napplication-label-hr:'Now in Android'\napplication-label-hu:'Now in Android'\napplication-label-hy:'Now in Android'\napplication-label-in:'Now in Android'\napplication-label-is:'Now in Android'\napplication-label-it:'Now in Android'\napplication-label-iw:'Now in Android'\napplication-label-ja:'Now in Android'\napplication-label-ka:'Now in Android'\napplication-label-kk:'Now in Android'\napplication-label-km:'Now in Android'\napplication-label-kn:'Now in Android'\napplication-label-ko:'Now in Android'\napplication-label-ky:'Now in Android'\napplication-label-lo:'Now in Android'\napplication-label-lt:'Now in Android'\napplication-label-lv:'Now in Android'\napplication-label-mk:'Now in Android'\napplication-label-ml:'Now in Android'\napplication-label-mn:'Now in Android'\napplication-label-mr:'Now in Android'\napplication-label-ms:'Now in Android'\napplication-label-my:'Now in Android'\napplication-label-nb:'Now in Android'\napplication-label-ne:'Now in Android'\napplication-label-nl:'Now in Android'\napplication-label-or:'Now in Android'\napplication-label-pa:'Now in Android'\napplication-label-pl:'Now in Android'\napplication-label-pt:'Now in Android'\napplication-label-pt-BR:'Now in Android'\napplication-label-pt-PT:'Now in Android'\napplication-label-ro:'Now in Android'\napplication-label-ru:'Now in Android'\napplication-label-si:'Now in Android'\napplication-label-sk:'Now in Android'\napplication-label-sl:'Now in Android'\napplication-label-sq:'Now in Android'\napplication-label-sr:'Now in Android'\napplication-label-sr-Latn:'Now in Android'\napplication-label-sv:'Now in Android'\napplication-label-sw:'Now in Android'\napplication-label-ta:'Now in Android'\napplication-label-te:'Now in Android'\napplication-label-th:'Now in Android'\napplication-label-tl:'Now in Android'\napplication-label-tr:'Now in Android'\napplication-label-uk:'Now in Android'\napplication-label-ur:'Now in Android'\napplication-label-uz:'Now in Android'\napplication-label-vi:'Now in Android'\napplication-label-zh-CN:'Now in Android'\napplication-label-zh-HK:'Now in Android'\napplication-label-zh-TW:'Now in Android'\napplication-label-zu:'Now in Android'\napplication-icon-120:'res/mipmap-anydpi-v26/ic_launcher.xml'\napplication-icon-160:'res/mipmap-anydpi-v26/ic_launcher.xml'\napplication-icon-240:'res/mipmap-anydpi-v26/ic_launcher.xml'\napplication-icon-320:'res/mipmap-anydpi-v26/ic_launcher.xml'\napplication-icon-480:'res/mipmap-anydpi-v26/ic_launcher.xml'\napplication-icon-640:'res/mipmap-anydpi-v26/ic_launcher.xml'\napplication-icon-65534:'res/mipmap-anydpi-v26/ic_launcher.xml'\napplication: label='Now in Android' icon='res/mipmap-anydpi-v26/ic_launcher.xml'\nlaunchable-activity: name='com.google.samples.apps.nowinandroid.MainActivity'  label='' icon=''\nuses-library-not-required:'android.ext.adservices'\nuses-library-not-required:'androidx.window.extensions'\nuses-library-not-required:'androidx.window.sidecar'\nfeature-group: label=''\n  uses-feature: name='android.hardware.faketouch'\n  uses-implied-feature: name='android.hardware.faketouch' reason='default feature for all apps'\nmain\nother-activities\nother-receivers\nother-services\nsupports-screens: 'small' 'normal' 'large' 'xlarge'\nsupports-any-density: 'true'\nlocales: '--_--' 'af' 'am' 'ar' 'as' 'az' 'be' 'bg' 'bn' 'bs' 'ca' 'cs' 'da' 'de' 'el' 'en-AU' 'en-CA' 'en-GB' 'en-IN' 'en-XC' 'es' 'es-US' 'et' 'eu' 'fa' 'fi' 'fr' 'fr-CA' 'gl' 'gu' 'hi' 'hr' 'hu' 'hy' 'in' 'is' 'it' 'iw' 'ja' 'ka' 'kk' 'km' 'kn' 'ko' 'ky' 'lo' 'lt' 'lv' 'mk' 'ml' 'mn' 'mr' 'ms' 'my' 'nb' 'ne' 'nl' 'or' 'pa' 'pl' 'pt' 'pt-BR' 'pt-PT' 'ro' 'ru' 'si' 'sk' 'sl' 'sq' 'sr' 'sr-Latn' 'sv' 'sw' 'ta' 'te' 'th' 'tl' 'tr' 'uk' 'ur' 'uz' 'vi' 'zh-CN' 'zh-HK' 'zh-TW' 'zu'\ndensities: '120' '160' '240' '320' '480' '640' '65534'\nnative-code: 'arm64-v8a' 'armeabi-v7a' 'x86' 'x86_64'\n"
  },
  {
    "path": "app/proguard-rules.pro",
    "content": "# Repackage classes into the default package to reduce the size of descriptors.\n-repackageclasses\n"
  },
  {
    "path": "app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.ui\n\nimport androidx.compose.ui.semantics.SemanticsActions.ScrollBy\nimport androidx.compose.ui.test.assertCountEquals\nimport androidx.compose.ui.test.assertIsDisplayed\nimport androidx.compose.ui.test.assertIsOn\nimport androidx.compose.ui.test.assertIsSelected\nimport androidx.compose.ui.test.hasTestTag\nimport androidx.compose.ui.test.hasText\nimport androidx.compose.ui.test.junit4.createAndroidComposeRule\nimport androidx.compose.ui.test.onAllNodesWithTag\nimport androidx.compose.ui.test.onAllNodesWithText\nimport androidx.compose.ui.test.onFirst\nimport androidx.compose.ui.test.onNodeWithContentDescription\nimport androidx.compose.ui.test.onNodeWithTag\nimport androidx.compose.ui.test.onNodeWithText\nimport androidx.compose.ui.test.performClick\nimport androidx.compose.ui.test.performScrollToNode\nimport androidx.test.espresso.Espresso\nimport androidx.test.espresso.NoActivityResumedException\nimport com.google.samples.apps.nowinandroid.MainActivity\nimport com.google.samples.apps.nowinandroid.R\nimport com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository\nimport com.google.samples.apps.nowinandroid.core.model.data.Topic\nimport com.google.samples.apps.nowinandroid.core.rules.GrantPostNotificationsPermissionRule\nimport com.google.samples.apps.nowinandroid.feature.interests.impl.LIST_PANE_TEST_TAG\nimport dagger.hilt.android.testing.HiltAndroidRule\nimport dagger.hilt.android.testing.HiltAndroidTest\nimport kotlinx.coroutines.flow.first\nimport kotlinx.coroutines.runBlocking\nimport org.junit.Before\nimport org.junit.Ignore\nimport org.junit.Rule\nimport org.junit.Test\nimport javax.inject.Inject\nimport com.google.samples.apps.nowinandroid.feature.bookmarks.api.R as BookmarksR\nimport com.google.samples.apps.nowinandroid.feature.foryou.api.R as FeatureForyouR\nimport com.google.samples.apps.nowinandroid.feature.search.api.R as FeatureSearchR\nimport com.google.samples.apps.nowinandroid.feature.settings.impl.R as SettingsR\n\n/**\n * Tests all the navigation flows that are handled by the navigation library.\n */\n@HiltAndroidTest\nclass NavigationTest {\n\n    /**\n     * Manages the components' state and is used to perform injection on your test\n     */\n    @get:Rule(order = 0)\n    val hiltRule = HiltAndroidRule(this)\n\n    /**\n     * Grant [android.Manifest.permission.POST_NOTIFICATIONS] permission.\n     */\n    @get:Rule(order = 1)\n    val postNotificationsPermission = GrantPostNotificationsPermissionRule()\n\n    /**\n     * Use the primary activity to initialize the app normally.\n     */\n    @get:Rule(order = 2)\n    val composeTestRule = createAndroidComposeRule<MainActivity>()\n\n    @Inject\n    lateinit var topicsRepository: TopicsRepository\n\n    @Inject\n    lateinit var newsRepository: NewsRepository\n\n    // The strings used for matching in these tests\n    private val navigateUp by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_api_navigate_up)\n    private val forYou by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_api_title)\n    private val interests by composeTestRule.stringResource(FeatureSearchR.string.feature_search_api_interests)\n    private val sampleTopic = \"Headlines\"\n    private val appName by composeTestRule.stringResource(R.string.app_name)\n    private val saved by composeTestRule.stringResource(BookmarksR.string.feature_bookmarks_api_title)\n    private val settings by composeTestRule.stringResource(SettingsR.string.feature_settings_impl_top_app_bar_action_icon_description)\n    private val brand by composeTestRule.stringResource(SettingsR.string.feature_settings_impl_brand_android)\n    private val ok by composeTestRule.stringResource(SettingsR.string.feature_settings_impl_dismiss_dialog_button_text)\n\n    @Before\n    fun setup() = hiltRule.inject()\n\n    @Test\n    fun firstScreen_isForYou() {\n        composeTestRule.apply {\n            // VERIFY for you is selected\n            onNodeWithText(forYou).assertIsSelected()\n        }\n    }\n\n    // TODO: implement tests related to navigation & resetting of destinations (b/213307564)\n    // Restoring content should be tested with another tab than the For You one, as that will\n    // still succeed even when restoring state is turned off.\n    /**\n     * When navigating between the different top level destinations, we should restore the state\n     * of previously visited destinations.\n     */\n    @Test\n    fun navigationBar_navigateToPreviouslySelectedTab_restoresContent() {\n        composeTestRule.apply {\n            // GIVEN the user follows a topic\n            onNodeWithText(sampleTopic).performClick()\n            // WHEN the user navigates to the Interests destination\n            onNodeWithText(interests).performClick()\n            // AND the user navigates to the For You destination\n            onNodeWithText(forYou).performClick()\n            // THEN the state of the For You destination is restored\n            onNodeWithContentDescription(sampleTopic).assertIsOn()\n        }\n    }\n\n    /**\n     * When reselecting a tab, it should show that tab's start destination and restore its state.\n     */\n    @Test\n    fun navigationBar_reselectTab_keepsState() {\n        composeTestRule.apply {\n            // GIVEN the user follows a topic\n            onNodeWithText(sampleTopic).performClick()\n            // WHEN the user taps the For You navigation bar item\n            onNodeWithText(forYou).performClick()\n            // THEN the state of the For You destination is restored\n            onNodeWithContentDescription(sampleTopic).assertIsOn()\n        }\n    }\n\n//    @Test\n//    fun navigationBar_reselectTab_resetsToStartDestination() {\n//        // GIVEN the user is on the Topics destination and scrolls\n//        // and navigates to the Topic Detail destination\n//        // WHEN the user taps the Topics navigation bar item\n//        // THEN the Topics destination shows in the same scrolled state\n//    }\n\n    /*\n     * Top level destinations should never show an up affordance.\n     */\n    @Test\n    fun topLevelDestinations_doNotShowUpArrow() {\n        composeTestRule.apply {\n            // GIVEN the user is on any of the top level destinations, THEN the Up arrow is not shown.\n            onNodeWithContentDescription(navigateUp).assertDoesNotExist()\n\n            onNodeWithText(saved).performClick()\n            onNodeWithContentDescription(navigateUp).assertDoesNotExist()\n\n            onNodeWithText(interests).performClick()\n            onNodeWithContentDescription(navigateUp).assertDoesNotExist()\n        }\n    }\n\n    @Test\n    fun topLevelDestinations_showTopBarWithTitle() {\n        composeTestRule.apply {\n            // Verify that the top bar contains the app name on the first screen.\n            onNodeWithText(appName).assertExists()\n\n            // Go to the saved tab, verify that the top bar contains \"saved\". This means\n            // we'll have 2 elements with the text \"saved\" on screen. One in the top bar, and\n            // one in the bottom navigation.\n            onNodeWithText(saved).performClick()\n            onAllNodesWithText(saved).assertCountEquals(2)\n\n            // As above but for the interests tab.\n            onNodeWithText(interests).performClick()\n            onAllNodesWithText(interests).assertCountEquals(2)\n        }\n    }\n\n    @Test\n    fun topLevelDestinations_showSettingsIcon() {\n        composeTestRule.apply {\n            onNodeWithContentDescription(settings).assertExists()\n\n            onNodeWithText(saved).performClick()\n            onNodeWithContentDescription(settings).assertExists()\n\n            onNodeWithText(interests).performClick()\n            onNodeWithContentDescription(settings).assertExists()\n        }\n    }\n\n    @Test\n    fun whenSettingsIconIsClicked_settingsDialogIsShown() {\n        composeTestRule.apply {\n            onNodeWithContentDescription(settings).performClick()\n\n            // Check that one of the settings is actually displayed.\n            onNodeWithText(brand).assertExists()\n        }\n    }\n\n    @Test\n    fun whenSettingsDialogDismissed_previousScreenIsDisplayed() {\n        composeTestRule.apply {\n            // Navigate to the saved screen, open the settings dialog, then close it.\n            onNodeWithText(saved).performClick()\n            onNodeWithContentDescription(settings).performClick()\n            onNodeWithText(ok).performClick()\n\n            // Check that the saved screen is still visible and selected.\n            onNode(hasText(saved) and hasTestTag(\"NiaNavItem\")).assertIsSelected()\n        }\n    }\n\n    /*\n     * There should always be at most one instance of a top-level destination at the same time.\n     */\n    @Test(expected = NoActivityResumedException::class)\n    fun homeDestination_back_quitsApp() {\n        composeTestRule.apply {\n            // GIVEN the user navigates to the Interests destination\n            onNodeWithText(interests).performClick()\n            // and then navigates to the For you destination\n            onNodeWithText(forYou).performClick()\n            // WHEN the user uses the system button/gesture to go back\n            Espresso.pressBack()\n            // THEN the app quits\n        }\n    }\n\n    /*\n     * When pressing back from any top level destination except \"For you\", the app navigates back\n     * to the \"For you\" destination, no matter which destinations you visited in between.\n     */\n    @Test\n    fun navigationBar_backFromAnyDestination_returnsToForYou() {\n        composeTestRule.apply {\n            // GIVEN the user navigated to the Interests destination\n            onNodeWithText(interests).performClick()\n            // TODO: Add another destination here to increase test coverage, see b/226357686.\n            // WHEN the user uses the system button/gesture to go back,\n            Espresso.pressBack()\n            // THEN the app shows the For You destination\n            onNodeWithText(forYou).assertExists()\n        }\n    }\n\n    // TODO decide if backStack should preserve previous stacks when navigating back to home tab (ForYou)\n    // https://github.com/android/nowinandroid/issues/1937\n    @Ignore\n    @Test\n    fun navigationBar_multipleBackStackInterests() {\n        composeTestRule.apply {\n            onNodeWithText(interests).performClick()\n\n            // Select the last topic\n            val topic = runBlocking {\n                topicsRepository.getTopics().first().sortedBy(Topic::name).last()\n            }\n            onNodeWithTag(LIST_PANE_TEST_TAG).performScrollToNode(hasText(topic.name))\n            onNodeWithText(topic.name).performClick()\n\n            // Verify the topic is still shown\n            onNodeWithTag(\"topic:${topic.id}\").assertIsDisplayed()\n\n            // Switch tab\n            onNodeWithText(forYou).performClick()\n            // Come back to Interests\n            onNodeWithText(interests).performClick()\n\n            // Verify the topic is still shown\n            onNodeWithTag(\"topic:${topic.id}\").assertExists()\n        }\n    }\n\n    @Test\n    fun navigatingToTopicFromForYou_showsTopicDetails() {\n        composeTestRule.apply {\n            // Get the first news resource\n            val newsResource = runBlocking {\n                newsRepository.getNewsResources().first().first()\n            }\n\n            // Get its first topic and follow it\n            val topic = newsResource.topics.first()\n            onNodeWithText(topic.name).performClick()\n\n            // Get the news feed and scroll to the news resource\n            // Note: Possible flakiness. If the content of the news resource is long then the topic\n            // tag might not be visible meaning it cannot be clicked\n            onNodeWithTag(\"forYou:feed\")\n                .performScrollToNode(hasTestTag(\"newsResourceCard:${newsResource.id}\"))\n                .fetchSemanticsNode()\n                .apply {\n                    val newsResourceCardNode = onNodeWithTag(\"newsResourceCard:${newsResource.id}\")\n                        .fetchSemanticsNode()\n                    config[ScrollBy].action?.invoke(\n                        0f,\n                        // to ensure the bottom of the card is visible,\n                        // manually scroll the difference between the height of\n                        // the scrolling node and the height of the card\n                        (newsResourceCardNode.size.height - size.height).coerceAtLeast(0).toFloat(),\n                    )\n                }\n\n            // Click the first topic tag\n            onAllNodesWithTag(\"topicTag:${topic.id}\", useUnmergedTree = true)\n                .onFirst()\n                .performClick()\n\n            // Verify that we're on the correct topic details screen\n            onNodeWithTag(\"topic:${topic.id}\").assertExists()\n        }\n    }\n}\n"
  },
  {
    "path": "app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/UiTestExtensions.kt",
    "content": "/*\n * Copyright 2024 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.ui\n\nimport androidx.annotation.StringRes\nimport androidx.compose.ui.test.junit4.AndroidComposeTestRule\nimport kotlin.properties.ReadOnlyProperty\n\nfun AndroidComposeTestRule<*, *>.stringResource(\n    @StringRes resId: Int,\n): ReadOnlyProperty<Any, String> =\n    ReadOnlyProperty { _, _ -> activity.getString(resId) }\n"
  },
  {
    "path": "app/src/benchmark/res/values/colors.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2023 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<resources>\n    <!-- Allow users to distinguish between build variants by having a different background color\n        for the launcher icon. See https://github.com/android/nowinandroid/pull/989. -->\n    <color name=\"ic_launcher_background_tint\">#000000</color>\n    <color name=\"ic_launcher_foreground_tint\">#FF006780</color>\n</resources>\n"
  },
  {
    "path": "app/src/benchmark/res/values-night/colors.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2023 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<resources>\n    <!-- Allow users to distinguish between build variants by having a different background color\n        for the launcher icon. See https://github.com/android/nowinandroid/pull/989. -->\n    <color name=\"ic_launcher_background_tint\">#FFFFFF</color>\n    <color name=\"ic_launcher_foreground_tint\">#FF006780</color>\n</resources>\n"
  },
  {
    "path": "app/src/debug/res/values/colors.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2023 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<resources>\n    <!-- Allow users to distinguish between build variants by having a different background color\n    for the launcher icon. See https://github.com/android/nowinandroid/pull/989. -->\n    <color name=\"ic_launcher_background_tint\">#000000</color>\n    <color name=\"ic_launcher_foreground_tint\">#FFA23F16</color>\n</resources>\n"
  },
  {
    "path": "app/src/debug/res/values-night/colors.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2023 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<resources>\n    <!-- Allow users to distinguish between build variants by having a different background color\n    for the launcher icon. See https://github.com/android/nowinandroid/pull/989. -->\n    <color name=\"ic_launcher_background_tint\">#FFFFFF</color>\n    <color name=\"ic_launcher_foreground_tint\">#FFA23F16</color>\n</resources>\n"
  },
  {
    "path": "app/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2021 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    xmlns:tools=\"http://schemas.android.com/tools\">\n\n    <uses-permission android:name=\"android.permission.INTERNET\" />\n\n    <!--\n    Firebase automatically adds these AD_ID and ADSERVICES permissions, even though we don't use them.\n    If you use these permissions you must declare how you're using them to Google Play, otherwise the\n    app will be rejected when publishing it. To avoid this we remove the permissions entirely.\n    -->\n    <uses-permission android:name=\"com.google.android.gms.permission.AD_ID\" tools:node=\"remove\"/>\n    <uses-permission android:name=\"android.permission.ACCESS_ADSERVICES_ATTRIBUTION\" tools:node=\"remove\"/>\n    <uses-permission android:name=\"android.permission.ACCESS_ADSERVICES_AD_ID\" tools:node=\"remove\"/>\n\n    <application\n        android:name=\".NiaApplication\"\n        android:allowBackup=\"true\"\n        android:enableOnBackInvokedCallback=\"true\"\n        android:icon=\"@mipmap/ic_launcher\"\n        android:label=\"@string/app_name\"\n        android:roundIcon=\"@mipmap/ic_launcher_round\"\n        android:supportsRtl=\"true\"\n        android:theme=\"@style/Theme.Nia.Splash\">\n        <profileable android:shell=\"true\" tools:targetApi=\"q\" />\n\n        <activity\n            android:name=\".MainActivity\"\n            android:configChanges=\"uiMode\"\n            android:exported=\"true\">\n            <intent-filter>\n                <action android:name=\"android.intent.action.MAIN\" />\n                <category android:name=\"android.intent.category.LAUNCHER\" />\n            </intent-filter>\n            <intent-filter>\n                <action android:name=\"android.intent.action.VIEW\" />\n\n                <category android:name=\"android.intent.category.DEFAULT\" />\n                <category android:name=\"android.intent.category.BROWSABLE\" />\n\n                <data android:scheme=\"https\" />\n                <data android:host=\"www.nowinandroid.apps.samples.google.com\" />\n            </intent-filter>\n        </activity>\n\n        <!-- Disable Firebase analytics by default. This setting is overwritten for the `prod`\n        flavor -->\n        <meta-data android:name=\"firebase_analytics_collection_deactivated\" android:value=\"true\" />\n        <!-- Disable collection of AD_ID for all build variants -->\n        <meta-data android:name=\"google_analytics_adid_collection_enabled\" android:value=\"false\" />\n        <!-- Firebase automatically adds the following property which we don't use so remove it -->\n        <property\n            android:name=\"android.adservices.AD_SERVICES_CONFIG\"\n            tools:node=\"remove\" />\n\n    </application>\n\n</manifest>\n"
  },
  {
    "path": "app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid\n\nimport android.os.Bundle\nimport androidx.activity.ComponentActivity\nimport androidx.activity.SystemBarStyle\nimport androidx.activity.compose.setContent\nimport androidx.activity.enableEdgeToEdge\nimport androidx.activity.viewModels\nimport androidx.compose.runtime.CompositionLocalProvider\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.setValue\nimport androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.compose.collectAsStateWithLifecycle\nimport androidx.lifecycle.lifecycleScope\nimport androidx.lifecycle.repeatOnLifecycle\nimport androidx.metrics.performance.JankStats\nimport androidx.tracing.trace\nimport com.google.samples.apps.nowinandroid.MainActivityUiState.Loading\nimport com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper\nimport com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper\nimport com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository\nimport com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor\nimport com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\nimport com.google.samples.apps.nowinandroid.core.ui.LocalTimeZone\nimport com.google.samples.apps.nowinandroid.ui.NiaApp\nimport com.google.samples.apps.nowinandroid.ui.rememberNiaAppState\nimport com.google.samples.apps.nowinandroid.util.isSystemInDarkTheme\nimport dagger.hilt.android.AndroidEntryPoint\nimport kotlinx.coroutines.flow.combine\nimport kotlinx.coroutines.flow.distinctUntilChanged\nimport kotlinx.coroutines.flow.map\nimport kotlinx.coroutines.flow.onEach\nimport kotlinx.coroutines.launch\nimport javax.inject.Inject\n\n@AndroidEntryPoint\nclass MainActivity : ComponentActivity() {\n\n    /**\n     * Lazily inject [JankStats], which is used to track jank throughout the app.\n     */\n    @Inject\n    lateinit var lazyStats: dagger.Lazy<JankStats>\n\n    @Inject\n    lateinit var networkMonitor: NetworkMonitor\n\n    @Inject\n    lateinit var timeZoneMonitor: TimeZoneMonitor\n\n    @Inject\n    lateinit var analyticsHelper: AnalyticsHelper\n\n    @Inject\n    lateinit var userNewsResourceRepository: UserNewsResourceRepository\n\n    private val viewModel: MainActivityViewModel by viewModels()\n\n    override fun onCreate(savedInstanceState: Bundle?) {\n        val splashScreen = installSplashScreen()\n        super.onCreate(savedInstanceState)\n\n        // We keep this as a mutable state, so that we can track changes inside the composition.\n        // This allows us to react to dark/light mode changes.\n        var themeSettings by mutableStateOf(\n            ThemeSettings(\n                darkTheme = resources.configuration.isSystemInDarkTheme,\n                androidTheme = Loading.shouldUseAndroidTheme,\n                disableDynamicTheming = Loading.shouldDisableDynamicTheming,\n            ),\n        )\n\n        // Update the uiState\n        lifecycleScope.launch {\n            lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {\n                combine(\n                    isSystemInDarkTheme(),\n                    viewModel.uiState,\n                ) { systemDark, uiState ->\n                    ThemeSettings(\n                        darkTheme = uiState.shouldUseDarkTheme(systemDark),\n                        androidTheme = uiState.shouldUseAndroidTheme,\n                        disableDynamicTheming = uiState.shouldDisableDynamicTheming,\n                    )\n                }\n                    .onEach { themeSettings = it }\n                    .map { it.darkTheme }\n                    .distinctUntilChanged()\n                    .collect { darkTheme ->\n                        trace(\"niaEdgeToEdge\") {\n                            // Turn off the decor fitting system windows, which allows us to handle insets,\n                            // including IME animations, and go edge-to-edge.\n                            // This is the same parameters as the default enableEdgeToEdge call, but we manually\n                            // resolve whether or not to show dark theme using uiState, since it can be different\n                            // than the configuration's dark theme value based on the user preference.\n                            enableEdgeToEdge(\n                                statusBarStyle = SystemBarStyle.auto(\n                                    lightScrim = android.graphics.Color.TRANSPARENT,\n                                    darkScrim = android.graphics.Color.TRANSPARENT,\n                                ) { darkTheme },\n                                navigationBarStyle = SystemBarStyle.auto(\n                                    lightScrim = lightScrim,\n                                    darkScrim = darkScrim,\n                                ) { darkTheme },\n                            )\n                        }\n                    }\n            }\n        }\n\n        // Keep the splash screen on-screen until the UI state is loaded. This condition is\n        // evaluated each time the app needs to be redrawn so it should be fast to avoid blocking\n        // the UI.\n        splashScreen.setKeepOnScreenCondition { viewModel.uiState.value.shouldKeepSplashScreen() }\n\n        setContent {\n            val appState = rememberNiaAppState(\n                networkMonitor = networkMonitor,\n                userNewsResourceRepository = userNewsResourceRepository,\n                timeZoneMonitor = timeZoneMonitor,\n            )\n\n            val currentTimeZone by appState.currentTimeZone.collectAsStateWithLifecycle()\n\n            CompositionLocalProvider(\n                LocalAnalyticsHelper provides analyticsHelper,\n                LocalTimeZone provides currentTimeZone,\n            ) {\n                NiaTheme(\n                    darkTheme = themeSettings.darkTheme,\n                    androidTheme = themeSettings.androidTheme,\n                    disableDynamicTheming = themeSettings.disableDynamicTheming,\n                ) {\n                    NiaApp(appState)\n                }\n            }\n        }\n    }\n\n    override fun onResume() {\n        super.onResume()\n        lazyStats.get().isTrackingEnabled = true\n    }\n\n    override fun onPause() {\n        super.onPause()\n        lazyStats.get().isTrackingEnabled = false\n    }\n}\n\n/**\n * The default light scrim, as defined by androidx and the platform:\n * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:activity/activity/src/main/java/androidx/activity/EdgeToEdge.kt;l=35-38;drc=27e7d52e8604a080133e8b842db10c89b4482598\n */\nprivate val lightScrim = android.graphics.Color.argb(0xe6, 0xFF, 0xFF, 0xFF)\n\n/**\n * The default dark scrim, as defined by androidx and the platform:\n * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:activity/activity/src/main/java/androidx/activity/EdgeToEdge.kt;l=40-44;drc=27e7d52e8604a080133e8b842db10c89b4482598\n */\nprivate val darkScrim = android.graphics.Color.argb(0x80, 0x1b, 0x1b, 0x1b)\n\n/**\n * Class for the system theme settings.\n * This wrapping class allows us to combine all the changes and prevent unnecessary recompositions.\n */\ndata class ThemeSettings(\n    val darkTheme: Boolean,\n    val androidTheme: Boolean,\n    val disableDynamicTheming: Boolean,\n)\n"
  },
  {
    "path": "app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivityViewModel.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid\n\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport com.google.samples.apps.nowinandroid.MainActivityUiState.Loading\nimport com.google.samples.apps.nowinandroid.MainActivityUiState.Success\nimport com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository\nimport com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig\nimport com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand\nimport com.google.samples.apps.nowinandroid.core.model.data.UserData\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport kotlinx.coroutines.flow.SharingStarted\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.map\nimport kotlinx.coroutines.flow.stateIn\nimport javax.inject.Inject\n\n@HiltViewModel\nclass MainActivityViewModel @Inject constructor(\n    userDataRepository: UserDataRepository,\n) : ViewModel() {\n    val uiState: StateFlow<MainActivityUiState> = userDataRepository.userData.map {\n        Success(it)\n    }.stateIn(\n        scope = viewModelScope,\n        initialValue = Loading,\n        started = SharingStarted.WhileSubscribed(5_000),\n    )\n}\n\nsealed interface MainActivityUiState {\n    data object Loading : MainActivityUiState\n\n    data class Success(val userData: UserData) : MainActivityUiState {\n        override val shouldDisableDynamicTheming = !userData.useDynamicColor\n\n        override val shouldUseAndroidTheme: Boolean = when (userData.themeBrand) {\n            ThemeBrand.DEFAULT -> false\n            ThemeBrand.ANDROID -> true\n        }\n\n        override fun shouldUseDarkTheme(isSystemDarkTheme: Boolean) =\n            when (userData.darkThemeConfig) {\n                DarkThemeConfig.FOLLOW_SYSTEM -> isSystemDarkTheme\n                DarkThemeConfig.LIGHT -> false\n                DarkThemeConfig.DARK -> true\n            }\n    }\n\n    /**\n     * Returns `true` if the state wasn't loaded yet and it should keep showing the splash screen.\n     */\n    fun shouldKeepSplashScreen() = this is Loading\n\n    /**\n     * Returns `true` if the dynamic color is disabled.\n     */\n    val shouldDisableDynamicTheming: Boolean get() = true\n\n    /**\n     * Returns `true` if the Android theme should be used.\n     */\n    val shouldUseAndroidTheme: Boolean get() = false\n\n    /**\n     * Returns `true` if dark theme should be used.\n     */\n    fun shouldUseDarkTheme(isSystemDarkTheme: Boolean) = isSystemDarkTheme\n}\n"
  },
  {
    "path": "app/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaApplication.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid\n\nimport android.app.Application\nimport android.content.pm.ApplicationInfo\nimport android.os.StrictMode\nimport android.os.StrictMode.ThreadPolicy.Builder\nimport coil.ImageLoader\nimport coil.ImageLoaderFactory\nimport com.google.samples.apps.nowinandroid.sync.initializers.Sync\nimport com.google.samples.apps.nowinandroid.util.ProfileVerifierLogger\nimport dagger.hilt.android.HiltAndroidApp\nimport javax.inject.Inject\n\n/**\n * [Application] class for NiA\n */\n@HiltAndroidApp\nclass NiaApplication : Application(), ImageLoaderFactory {\n    @Inject\n    lateinit var imageLoader: dagger.Lazy<ImageLoader>\n\n    @Inject\n    lateinit var profileVerifierLogger: ProfileVerifierLogger\n\n    override fun onCreate() {\n        super.onCreate()\n\n        setStrictModePolicy()\n\n        // Initialize Sync; the system responsible for keeping data in the app up to date.\n        Sync.initialize(context = this)\n        profileVerifierLogger()\n    }\n\n    override fun newImageLoader(): ImageLoader = imageLoader.get()\n\n    /**\n     * Return true if the application is debuggable.\n     */\n    private fun isDebuggable(): Boolean {\n        return 0 != applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE\n    }\n\n    /**\n     * Set a thread policy that detects all potential problems on the main thread, such as network\n     * and disk access.\n     *\n     * If a problem is found, the offending call will be logged and the application will be killed.\n     */\n    private fun setStrictModePolicy() {\n        if (isDebuggable()) {\n            StrictMode.setThreadPolicy(\n                Builder().detectAll().penaltyLog().build(),\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "app/src/main/kotlin/com/google/samples/apps/nowinandroid/di/JankStatsModule.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.di\n\nimport android.app.Activity\nimport android.util.Log\nimport android.view.Window\nimport androidx.metrics.performance.JankStats\nimport androidx.metrics.performance.JankStats.OnFrameListener\nimport dagger.Module\nimport dagger.Provides\nimport dagger.hilt.InstallIn\nimport dagger.hilt.android.components.ActivityComponent\n\n@Module\n@InstallIn(ActivityComponent::class)\nobject JankStatsModule {\n    @Provides\n    fun providesOnFrameListener(): OnFrameListener = OnFrameListener { frameData ->\n        // Make sure to only log janky frames.\n        if (frameData.isJank) {\n            // We're currently logging this but would better report it to a backend.\n            Log.v(\"NiA Jank\", frameData.toString())\n        }\n    }\n\n    @Provides\n    fun providesWindow(activity: Activity): Window = activity.window\n\n    @Provides\n    fun providesJankStats(\n        window: Window,\n        frameListener: OnFrameListener,\n    ): JankStats = JankStats.createAndTrack(window, frameListener)\n}\n"
  },
  {
    "path": "app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/TopLevelNavItem.kt",
    "content": "/*\n * Copyright 2025 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.navigation\n\nimport androidx.annotation.StringRes\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport com.google.samples.apps.nowinandroid.R\nimport com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons\nimport com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation.BookmarksNavKey\nimport com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouNavKey\nimport com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsNavKey\nimport com.google.samples.apps.nowinandroid.feature.bookmarks.api.R as bookmarksR\nimport com.google.samples.apps.nowinandroid.feature.foryou.api.R as forYouR\nimport com.google.samples.apps.nowinandroid.feature.search.api.R as searchR\n\n/**\n * Type for the top level navigation items in the application. Contains UI information about the\n * current route that is used in the top app bar and common navigation UI.\n *\n * @param selectedIcon The icon to be displayed in the navigation UI when this destination is\n * selected.\n * @param unselectedIcon The icon to be displayed in the navigation UI when this destination is\n * not selected.\n * @param iconTextId Text that to be displayed in the navigation UI.\n * @param titleTextId Text that is displayed on the top app bar.\n */\ndata class TopLevelNavItem(\n    val selectedIcon: ImageVector,\n    val unselectedIcon: ImageVector,\n    @StringRes val iconTextId: Int,\n    @StringRes val titleTextId: Int,\n)\n\nval FOR_YOU = TopLevelNavItem(\n    selectedIcon = NiaIcons.Upcoming,\n    unselectedIcon = NiaIcons.UpcomingBorder,\n    iconTextId = forYouR.string.feature_foryou_api_title,\n    titleTextId = R.string.app_name,\n)\n\nval BOOKMARKS = TopLevelNavItem(\n    selectedIcon = NiaIcons.Bookmarks,\n    unselectedIcon = NiaIcons.BookmarksBorder,\n    iconTextId = bookmarksR.string.feature_bookmarks_api_title,\n    titleTextId = bookmarksR.string.feature_bookmarks_api_title,\n)\n\nval INTERESTS = TopLevelNavItem(\n    selectedIcon = NiaIcons.Grid3x3,\n    unselectedIcon = NiaIcons.Grid3x3,\n    iconTextId = searchR.string.feature_search_api_interests,\n    titleTextId = searchR.string.feature_search_api_interests,\n)\n\nval TOP_LEVEL_NAV_ITEMS = mapOf(\n    ForYouNavKey to FOR_YOU,\n    BookmarksNavKey to BOOKMARKS,\n    InterestsNavKey(null) to INTERESTS,\n)\n"
  },
  {
    "path": "app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.ui\n\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.WindowInsets\nimport androidx.compose.foundation.layout.WindowInsetsSides\nimport androidx.compose.foundation.layout.consumeWindowInsets\nimport androidx.compose.foundation.layout.exclude\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.ime\nimport androidx.compose.foundation.layout.only\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.safeDrawing\nimport androidx.compose.foundation.layout.windowInsetsPadding\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Scaffold\nimport androidx.compose.material3.SnackbarDuration.Indefinite\nimport androidx.compose.material3.SnackbarHost\nimport androidx.compose.material3.SnackbarHostState\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TopAppBarDefaults\nimport androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi\nimport androidx.compose.material3.adaptive.WindowAdaptiveInfo\nimport androidx.compose.material3.adaptive.currentWindowAdaptiveInfo\nimport androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.CompositionLocalProvider\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.saveable.rememberSaveable\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.ExperimentalComposeUiApi\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.composed\nimport androidx.compose.ui.draw.drawWithContent\nimport androidx.compose.ui.geometry.Offset\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.testTag\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.semantics.semantics\nimport androidx.compose.ui.semantics.testTagsAsResourceId\nimport androidx.compose.ui.unit.dp\nimport androidx.lifecycle.compose.collectAsStateWithLifecycle\nimport androidx.navigation3.runtime.NavKey\nimport androidx.navigation3.runtime.entryProvider\nimport androidx.navigation3.ui.NavDisplay\nimport com.google.samples.apps.nowinandroid.R\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaGradientBackground\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationSuiteScaffold\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAppBar\nimport com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors\nimport com.google.samples.apps.nowinandroid.core.navigation.Navigator\nimport com.google.samples.apps.nowinandroid.core.navigation.toEntries\nimport com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation.LocalSnackbarHostState\nimport com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation.bookmarksEntry\nimport com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouNavKey\nimport com.google.samples.apps.nowinandroid.feature.foryou.impl.navigation.forYouEntry\nimport com.google.samples.apps.nowinandroid.feature.interests.impl.navigation.interestsEntry\nimport com.google.samples.apps.nowinandroid.feature.search.api.navigation.SearchNavKey\nimport com.google.samples.apps.nowinandroid.feature.search.impl.navigation.searchEntry\nimport com.google.samples.apps.nowinandroid.feature.settings.impl.SettingsDialog\nimport com.google.samples.apps.nowinandroid.feature.topic.impl.navigation.topicEntry\nimport com.google.samples.apps.nowinandroid.navigation.TOP_LEVEL_NAV_ITEMS\nimport com.google.samples.apps.nowinandroid.feature.settings.impl.R as settingsR\n\n@Composable\nfun NiaApp(\n    appState: NiaAppState,\n    modifier: Modifier = Modifier,\n    windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(),\n) {\n    val shouldShowGradientBackground = appState.navigationState.currentTopLevelKey == ForYouNavKey\n    var showSettingsDialog by rememberSaveable { mutableStateOf(false) }\n\n    NiaBackground(modifier = modifier) {\n        NiaGradientBackground(\n            gradientColors = if (shouldShowGradientBackground) {\n                LocalGradientColors.current\n            } else {\n                GradientColors()\n            },\n        ) {\n            val snackbarHostState = remember { SnackbarHostState() }\n\n            val isOffline by appState.isOffline.collectAsStateWithLifecycle()\n\n            // If user is not connected to the internet show a snack bar to inform them.\n            val notConnectedMessage = stringResource(R.string.not_connected)\n            LaunchedEffect(isOffline) {\n                if (isOffline) {\n                    snackbarHostState.showSnackbar(\n                        message = notConnectedMessage,\n                        duration = Indefinite,\n                    )\n                }\n            }\n            CompositionLocalProvider(LocalSnackbarHostState provides snackbarHostState) {\n                NiaApp(\n                    appState = appState,\n\n                    // TODO: Settings should be a dialog screen\n                    showSettingsDialog = showSettingsDialog,\n                    onSettingsDismissed = { showSettingsDialog = false },\n                    onTopAppBarActionClick = { showSettingsDialog = true },\n                    windowAdaptiveInfo = windowAdaptiveInfo,\n                )\n            }\n        }\n    }\n}\n\n@Composable\n@OptIn(\n    ExperimentalMaterial3Api::class,\n    ExperimentalComposeUiApi::class,\n    ExperimentalMaterial3AdaptiveApi::class,\n)\ninternal fun NiaApp(\n    appState: NiaAppState,\n    showSettingsDialog: Boolean,\n    onSettingsDismissed: () -> Unit,\n    onTopAppBarActionClick: () -> Unit,\n    modifier: Modifier = Modifier,\n    windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(),\n) {\n    val unreadNavKeys by appState.topLevelNavKeysWithUnreadResources\n        .collectAsStateWithLifecycle()\n\n    if (showSettingsDialog) {\n        SettingsDialog(\n            onDismiss = { onSettingsDismissed() },\n        )\n    }\n\n    val snackbarHostState = LocalSnackbarHostState.current\n\n    val navigator = remember { Navigator(appState.navigationState) }\n\n    NiaNavigationSuiteScaffold(\n        navigationSuiteItems = {\n            TOP_LEVEL_NAV_ITEMS.forEach { (navKey, navItem) ->\n                val hasUnread = unreadNavKeys.contains(navKey)\n                val selected = navKey == appState.navigationState.currentTopLevelKey\n                item(\n                    selected = selected,\n                    onClick = { navigator.navigate(navKey) },\n                    icon = {\n                        Icon(\n                            imageVector = navItem.unselectedIcon,\n                            contentDescription = null,\n                        )\n                    },\n                    selectedIcon = {\n                        Icon(\n                            imageVector = navItem.selectedIcon,\n                            contentDescription = null,\n                        )\n                    },\n                    label = { Text(stringResource(navItem.iconTextId)) },\n                    modifier = Modifier\n                        .testTag(\"NiaNavItem\")\n                        .then(if (hasUnread) Modifier.notificationDot() else Modifier),\n                )\n            }\n        },\n        windowAdaptiveInfo = windowAdaptiveInfo,\n    ) {\n        Scaffold(\n            modifier = modifier.semantics {\n                testTagsAsResourceId = true\n            },\n            containerColor = Color.Transparent,\n            contentColor = MaterialTheme.colorScheme.onBackground,\n            contentWindowInsets = WindowInsets(0, 0, 0, 0),\n            snackbarHost = {\n                SnackbarHost(\n                    snackbarHostState,\n                    modifier = Modifier.windowInsetsPadding(\n                        WindowInsets.safeDrawing.exclude(\n                            WindowInsets.ime,\n                        ),\n                    ),\n                )\n            },\n        ) { padding ->\n            Column(\n                Modifier\n                    .fillMaxSize()\n                    .padding(padding)\n                    .consumeWindowInsets(padding)\n                    .windowInsetsPadding(\n                        WindowInsets.safeDrawing.only(\n                            WindowInsetsSides.Horizontal,\n                        ),\n                    ),\n            ) {\n                // Only show the top app bar on top level destinations.\n                var shouldShowTopAppBar = false\n\n                if (appState.navigationState.currentKey in appState.navigationState.topLevelKeys) {\n                    shouldShowTopAppBar = true\n\n                    val destination = TOP_LEVEL_NAV_ITEMS[appState.navigationState.currentTopLevelKey]\n                        ?: error(\"Top level nav item not found for ${appState.navigationState.currentTopLevelKey}\")\n\n                    NiaTopAppBar(\n                        titleRes = destination.titleTextId,\n                        navigationIcon = NiaIcons.Search,\n                        navigationIconContentDescription = stringResource(\n                            id = settingsR.string.feature_settings_impl_top_app_bar_navigation_icon_description,\n                        ),\n                        actionIcon = NiaIcons.Settings,\n                        actionIconContentDescription = stringResource(\n                            id = settingsR.string.feature_settings_impl_top_app_bar_action_icon_description,\n                        ),\n                        colors = TopAppBarDefaults.topAppBarColors(\n                            containerColor = Color.Transparent,\n                        ),\n                        onActionClick = { onTopAppBarActionClick() },\n                        onNavigationClick = { navigator.navigate(SearchNavKey) },\n                    )\n                }\n\n                Box(\n                    // Workaround for https://issuetracker.google.com/338478720\n                    modifier = Modifier.consumeWindowInsets(\n                        if (shouldShowTopAppBar) {\n                            WindowInsets.safeDrawing.only(WindowInsetsSides.Top)\n                        } else {\n                            WindowInsets(0, 0, 0, 0)\n                        },\n                    ),\n                ) {\n                    val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()\n\n                    val entryProvider = entryProvider {\n                        forYouEntry(navigator)\n                        bookmarksEntry(navigator)\n                        interestsEntry(navigator)\n                        topicEntry(navigator)\n                        searchEntry(navigator)\n                    }\n\n                    NavDisplay(\n                        entries = appState.navigationState.toEntries(entryProvider),\n                        sceneStrategy = listDetailStrategy,\n                        onBack = { navigator.goBack() },\n                    )\n                }\n\n                // TODO: We may want to add padding or spacer when the snackbar is shown so that\n                //  content doesn't display behind it.\n            }\n        }\n    }\n}\n\nprivate fun Modifier.notificationDot(): Modifier =\n    composed {\n        val tertiaryColor = MaterialTheme.colorScheme.tertiary\n        drawWithContent {\n            drawContent()\n            drawCircle(\n                tertiaryColor,\n                radius = 5.dp.toPx(),\n                // This is based on the dimensions of the NavigationBar's \"indicator pill\";\n                // however, its parameters are private, so we must depend on them implicitly\n                // (NavigationBarTokens.ActiveIndicatorWidth = 64.dp)\n                center = center + Offset(\n                    64.dp.toPx() * .45f,\n                    32.dp.toPx() * -.45f - 6.dp.toPx(),\n                ),\n            )\n        }\n    }\n"
  },
  {
    "path": "app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.ui\n\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.Stable\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.navigation3.runtime.NavKey\nimport com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository\nimport com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor\nimport com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor\nimport com.google.samples.apps.nowinandroid.core.navigation.NavigationState\nimport com.google.samples.apps.nowinandroid.core.navigation.rememberNavigationState\nimport com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank\nimport com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation.BookmarksNavKey\nimport com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouNavKey\nimport com.google.samples.apps.nowinandroid.navigation.TOP_LEVEL_NAV_ITEMS\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.flow.SharingStarted\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.combine\nimport kotlinx.coroutines.flow.map\nimport kotlinx.coroutines.flow.stateIn\nimport kotlinx.datetime.TimeZone\n\n@Composable\nfun rememberNiaAppState(\n    networkMonitor: NetworkMonitor,\n    userNewsResourceRepository: UserNewsResourceRepository,\n    timeZoneMonitor: TimeZoneMonitor,\n    coroutineScope: CoroutineScope = rememberCoroutineScope(),\n): NiaAppState {\n    val navigationState = rememberNavigationState(ForYouNavKey, TOP_LEVEL_NAV_ITEMS.keys)\n\n    NavigationTrackingSideEffect(navigationState)\n\n    return remember(\n        navigationState,\n        coroutineScope,\n        networkMonitor,\n        userNewsResourceRepository,\n        timeZoneMonitor,\n    ) {\n        NiaAppState(\n            navigationState = navigationState,\n            coroutineScope = coroutineScope,\n            networkMonitor = networkMonitor,\n            userNewsResourceRepository = userNewsResourceRepository,\n            timeZoneMonitor = timeZoneMonitor,\n        )\n    }\n}\n\n@Stable\nclass NiaAppState(\n    val navigationState: NavigationState,\n    coroutineScope: CoroutineScope,\n    networkMonitor: NetworkMonitor,\n    userNewsResourceRepository: UserNewsResourceRepository,\n    timeZoneMonitor: TimeZoneMonitor,\n) {\n    val isOffline = networkMonitor.isOnline\n        .map(Boolean::not)\n        .stateIn(\n            scope = coroutineScope,\n            started = SharingStarted.WhileSubscribed(5_000),\n            initialValue = false,\n        )\n\n    /**\n     * The top level nav keys that have unread news resources.\n     */\n    val topLevelNavKeysWithUnreadResources: StateFlow<Set<NavKey>> =\n        userNewsResourceRepository.observeAllForFollowedTopics()\n            .combine(userNewsResourceRepository.observeAllBookmarked()) { forYouNewsResources, bookmarkedNewsResources ->\n                setOfNotNull(\n                    ForYouNavKey.takeIf { forYouNewsResources.any { !it.hasBeenViewed } },\n                    BookmarksNavKey.takeIf { bookmarkedNewsResources.any { !it.hasBeenViewed } },\n                )\n            }\n            .stateIn(\n                coroutineScope,\n                SharingStarted.WhileSubscribed(5_000),\n                initialValue = emptySet(),\n            )\n\n    val currentTimeZone = timeZoneMonitor.currentTimeZone\n        .stateIn(\n            coroutineScope,\n            SharingStarted.WhileSubscribed(5_000),\n            TimeZone.currentSystemDefault(),\n        )\n}\n\n/**\n * Stores information about navigation events to be used with JankStats\n */\n@Composable\nprivate fun NavigationTrackingSideEffect(navigationState: NavigationState) {\n    TrackDisposableJank(navigationState.currentKey) { metricsHolder ->\n        metricsHolder.state?.putState(\"Navigation\", navigationState.currentKey.toString())\n        onDispose {}\n    }\n}\n"
  },
  {
    "path": "app/src/main/kotlin/com/google/samples/apps/nowinandroid/util/ProfileVerifierLogger.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.util\n\nimport android.util.Log\nimport androidx.profileinstaller.ProfileVerifier\nimport com.google.samples.apps.nowinandroid.core.common.network.di.ApplicationScope\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.guava.await\nimport kotlinx.coroutines.launch\nimport javax.inject.Inject\n\n/**\n * Logs the app's Baseline Profile Compilation Status using [ProfileVerifier].\n *\n * When delivering through Google Play, the baseline profile is compiled during installation.\n * In this case you will see the correct state logged without any further action necessary.\n * To verify baseline profile installation locally, you need to manually trigger baseline\n * profile installation.\n *\n * For immediate compilation, call:\n * ```bash\n * adb shell cmd package compile -f -m speed-profile com.example.macrobenchmark.target\n * ```\n * You can also trigger background optimizations:\n * ```bash\n * adb shell pm bg-dexopt-job\n * ```\n * Both jobs run asynchronously and might take some time complete.\n *\n * To see quick turnaround of the ProfileVerifier, we recommend using `speed-profile`.\n * If you don't do either of these steps, you might only see the profile status reported as\n * \"enqueued for compilation\" when running the sample locally.\n *\n * @see androidx.profileinstaller.ProfileVerifier.CompilationStatus.ResultCode\n */\nclass ProfileVerifierLogger @Inject constructor(\n    @ApplicationScope private val scope: CoroutineScope,\n) {\n    companion object {\n        private const val TAG = \"ProfileInstaller\"\n    }\n\n    operator fun invoke() = scope.launch {\n        val status = ProfileVerifier.getCompilationStatusAsync().await()\n        Log.d(TAG, \"Status code: ${status.profileInstallResultCode}\")\n        Log.d(\n            TAG,\n            when {\n                status.isCompiledWithProfile -> \"App compiled with profile\"\n                status.hasProfileEnqueuedForCompilation() -> \"Profile enqueued for compilation\"\n                else -> \"Profile not compiled nor enqueued\"\n            },\n        )\n    }\n}\n"
  },
  {
    "path": "app/src/main/kotlin/com/google/samples/apps/nowinandroid/util/UiExtensions.kt",
    "content": "/*\n * Copyright 2024 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.util\n\nimport android.content.res.Configuration\nimport androidx.activity.ComponentActivity\nimport androidx.core.util.Consumer\nimport kotlinx.coroutines.channels.awaitClose\nimport kotlinx.coroutines.flow.callbackFlow\nimport kotlinx.coroutines.flow.conflate\nimport kotlinx.coroutines.flow.distinctUntilChanged\n\n/**\n * Convenience wrapper for dark mode checking\n */\nval Configuration.isSystemInDarkTheme\n    get() = (uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES\n\n/**\n * Registers listener for configuration changes to retrieve whether system is in dark theme or not.\n * Immediately upon subscribing, it sends the current value and then registers listener for changes.\n */\nfun ComponentActivity.isSystemInDarkTheme() = callbackFlow {\n    channel.trySend(resources.configuration.isSystemInDarkTheme)\n\n    val listener = Consumer<Configuration> {\n        channel.trySend(it.isSystemInDarkTheme)\n    }\n\n    addOnConfigurationChangedListener(listener)\n\n    awaitClose { removeOnConfigurationChangedListener(listener) }\n}\n    .distinctUntilChanged()\n    .conflate()\n"
  },
  {
    "path": "app/src/main/res/drawable/ic_launcher_background.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"108dp\"\n    android:height=\"108dp\"\n    android:viewportWidth=\"108\"\n    android:viewportHeight=\"108\">\n  <path\n      android:pathData=\"M0,0h108v108h-108z\"\n      android:fillColor=\"@color/ic_launcher_background_tint\"/>\n</vector>\n"
  },
  {
    "path": "app/src/main/res/drawable/ic_launcher_foreground.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"108dp\"\n    android:height=\"108dp\"\n    android:viewportWidth=\"108\"\n    android:viewportHeight=\"108\">\n  <path\n      android:pathData=\"M65.08,84.13a1.94,1.94 0,1 1,-0.01 -3.9,1.94 1.94,0 0,1 0.01,3.9ZM43.6,84.13a1.94,1.94 0,1 1,-0.01 -3.9,1.94 1.94,0 0,1 0.01,3.9ZM65.77,72.44 L69.66,65.73a0.81,0.81 0,0 0,-0.3 -1.1,0.82 0.82,0 0,0 -1.11,0.3l-3.93,6.8a24,24 0,0 0,-9.99 -2.14c-3.6,0 -6.98,0.77 -9.99,2.14l-3.93,-6.8a0.8,0.8 0,1 0,-1.4 0.8l3.88,6.71A22.91,22.91 0,0 0,31 90.77h46.67a22.9,22.9 0,0 0,-11.9 -18.33Z\"\n      android:fillColor=\"@color/ic_launcher_foreground_tint\"/>\n  <path\n      android:pathData=\"M46.57,35a0.85,0.85 0,0 0,-0.85 0.85v7.3h-1.53a1.52,1.52 0,0 0,0 3.05h1.53v-3.05h1.7c0.75,0 1.36,-0.61 1.36,-1.36v-4.07h1.19c0.46,0 0.84,-0.38 0.84,-0.85v-1.02a0.85,0.85 0,0 0,-0.84 -0.85h-3.4ZM46.57,54.35h3.4c0.46,0 0.84,-0.38 0.84,-0.85v-1.02a0.85,0.85 0,0 0,-0.84 -0.84h-1.19v-4.08c0,-0.75 -0.61,-1.36 -1.36,-1.36h-1.7v7.3c0,0.47 0.38,0.85 0.85,0.85ZM61.54,35c0.47,0 0.85,0.38 0.85,0.85v7.3h1.53a1.52,1.52 0,0 1,0 3.05h-1.53v-3.05h-1.7c-0.75,0 -1.36,-0.61 -1.36,-1.36v-4.07h-1.18a0.85,0.85 0,0 1,-0.85 -0.85v-1.02c0,-0.47 0.38,-0.85 0.85,-0.85h3.39ZM61.54,54.35h-3.39a0.85,0.85 0,0 1,-0.85 -0.85v-1.02c0,-0.46 0.38,-0.84 0.85,-0.84h1.18v-4.08c0,-0.75 0.61,-1.36 1.36,-1.36h1.7v7.3c0,0.47 -0.38,0.85 -0.85,0.85Z\"\n      android:fillColor=\"@color/ic_launcher_foreground_tint\"\n      android:fillType=\"evenOdd\"/>\n</vector>\n"
  },
  {
    "path": "app/src/main/res/drawable/ic_splash.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"108dp\"\n    android:height=\"108dp\"\n    android:viewportWidth=\"108\"\n    android:viewportHeight=\"108\">\n\n    <path\n        android:pathData=\"M0,0h108v108h-108z\"\n        android:fillColor=\"@color/ic_launcher_background_tint\"/>\n    <path\n        android:pathData=\"M65.08,84.13a1.94,1.94 0,1 1,-0.01 -3.9,1.94 1.94,0 0,1 0.01,3.9ZM43.6,84.13a1.94,1.94 0,1 1,-0.01 -3.9,1.94 1.94,0 0,1 0.01,3.9ZM65.77,72.44 L69.66,65.73a0.81,0.81 0,0 0,-0.3 -1.1,0.82 0.82,0 0,0 -1.11,0.3l-3.93,6.8a24,24 0,0 0,-9.99 -2.14c-3.6,0 -6.98,0.77 -9.99,2.14l-3.93,-6.8a0.8,0.8 0,1 0,-1.4 0.8l3.88,6.71A22.91,22.91 0,0 0,31 90.77h46.67a22.9,22.9 0,0 0,-11.9 -18.33Z\"\n        android:fillColor=\"@color/ic_launcher_foreground_tint\"/>\n    <path\n        android:pathData=\"M46.57,35a0.85,0.85 0,0 0,-0.85 0.85v7.3h-1.53a1.52,1.52 0,0 0,0 3.05h1.53v-3.05h1.7c0.75,0 1.36,-0.61 1.36,-1.36v-4.07h1.19c0.46,0 0.84,-0.38 0.84,-0.85v-1.02a0.85,0.85 0,0 0,-0.84 -0.85h-3.4ZM46.57,54.35h3.4c0.46,0 0.84,-0.38 0.84,-0.85v-1.02a0.85,0.85 0,0 0,-0.84 -0.84h-1.19v-4.08c0,-0.75 -0.61,-1.36 -1.36,-1.36h-1.7v7.3c0,0.47 0.38,0.85 0.85,0.85ZM61.54,35c0.47,0 0.85,0.38 0.85,0.85v7.3h1.53a1.52,1.52 0,0 1,0 3.05h-1.53v-3.05h-1.7c-0.75,0 -1.36,-0.61 -1.36,-1.36v-4.07h-1.18a0.85,0.85 0,0 1,-0.85 -0.85v-1.02c0,-0.47 0.38,-0.85 0.85,-0.85h3.39ZM61.54,54.35h-3.39a0.85,0.85 0,0 1,-0.85 -0.85v-1.02c0,-0.46 0.38,-0.84 0.85,-0.84h1.18v-4.08c0,-0.75 0.61,-1.36 1.36,-1.36h1.7v7.3c0,0.47 -0.38,0.85 -0.85,0.85Z\"\n        android:fillColor=\"@color/ic_launcher_foreground_tint\"\n        android:fillType=\"evenOdd\"/>\n\n</vector>\n"
  },
  {
    "path": "app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <background android:drawable=\"@drawable/ic_launcher_background\"/>\n    <foreground android:drawable=\"@drawable/ic_launcher_foreground\"/>\n    <monochrome android:drawable=\"@drawable/ic_launcher_foreground\" />\n</adaptive-icon>\n"
  },
  {
    "path": "app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <background android:drawable=\"@drawable/ic_launcher_background\"/>\n    <foreground android:drawable=\"@drawable/ic_launcher_foreground\"/>\n    <monochrome android:drawable=\"@drawable/ic_launcher_foreground\" />\n</adaptive-icon>\n"
  },
  {
    "path": "app/src/main/res/values/colors.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2021 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<resources>\n    <color name=\"ic_launcher_background_tint\">#000000</color>\n    <color name=\"ic_launcher_foreground_tint\">#FCFCFC</color>\n</resources>\n"
  },
  {
    "path": "app/src/main/res/values/strings.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2021 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<resources>\n    <string name=\"app_name\">Now in Android</string>\n    <string name=\"not_connected\">⚠️ You aren’t connected to the internet</string>\n</resources>\n"
  },
  {
    "path": "app/src/main/res/values/themes.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2021 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<resources xmlns:tools=\"http://schemas.android.com/tools\">\n\n    <!-- Allows us to override night specific attributes in the\n         values-night folder. -->\n    <style name=\"NightAdjusted.Theme.Nia\" parent=\"android:Theme.Material.Light.NoActionBar\" />\n\n    <!-- The final theme we use -->\n    <style name=\"Theme.Nia\" parent=\"NightAdjusted.Theme.Nia\">\n        <item name=\"android:forceDarkAllowed\" tools:targetApi=\"29\">false</item>\n    </style>\n\n    <style name=\"NightAdjusted.Theme.Splash\" parent=\"Theme.SplashScreen\">\n        <item name=\"android:windowLightStatusBar\" tools:targetApi=\"23\">true</item>\n        <item name=\"android:windowLightNavigationBar\" tools:targetApi=\"27\">true</item>\n    </style>\n\n    <style name=\"Theme.Nia.Splash\" parent=\"NightAdjusted.Theme.Splash\">\n        <item name=\"windowSplashScreenAnimatedIcon\">@drawable/ic_splash</item>\n        <item name=\"postSplashScreenTheme\">@style/Theme.Nia</item>\n    </style>\n\n</resources>\n"
  },
  {
    "path": "app/src/main/res/values-night/colors.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2021 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<resources>\n    <color name=\"ic_launcher_background_tint\">#FCFCFC</color>\n    <color name=\"ic_launcher_foreground_tint\">#000000</color>\n</resources>\n"
  },
  {
    "path": "app/src/main/res/values-night/themes.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2021 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<resources xmlns:tools=\"http://schemas.android.com/tools\">\n\n    <style name=\"NightAdjusted.Theme.Nia\" parent=\"android:Theme.Material.NoActionBar\" />\n\n    <style name=\"NightAdjusted.Theme.Splash\" parent=\"Theme.SplashScreen\">\n        <item name=\"android:windowLightStatusBar\" tools:targetApi=\"23\">false</item>\n        <item name=\"android:windowLightNavigationBar\" tools:targetApi=\"27\">false</item>\n    </style>\n\n</resources>\n"
  },
  {
    "path": "app/src/prod/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2023 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    xmlns:tools=\"http://schemas.android.com/tools\">\n    <application>\n        <!-- Enable Firebase analytics for `prod` builds -->\n        <meta-data\n            tools:replace=\"android:value\"\n            android:name=\"firebase_analytics_collection_deactivated\"\n            android:value=\"false\" />\n    </application>\n</manifest>\n"
  },
  {
    "path": "app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/DeviceConfigurationOverrideWindowInsets.kt",
    "content": "/*\n * Copyright 2024 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.ui\n\nimport android.view.WindowInsets\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.rememberUpdatedState\nimport androidx.compose.ui.platform.AbstractComposeView\nimport androidx.compose.ui.test.DeviceConfigurationOverride\nimport androidx.compose.ui.viewinterop.AndroidView\nimport androidx.core.view.WindowInsetsCompat\nimport androidx.core.view.children\n\n/**\n * A [DeviceConfigurationOverride] that overrides the window insets for the contained content.\n */\n@Suppress(\"ktlint:standard:function-naming\")\nfun DeviceConfigurationOverride.Companion.WindowInsets(\n    windowInsets: WindowInsetsCompat,\n): DeviceConfigurationOverride = DeviceConfigurationOverride { contentUnderTest ->\n    val currentContentUnderTest by rememberUpdatedState(contentUnderTest)\n    val currentWindowInsets by rememberUpdatedState(windowInsets)\n    AndroidView(\n        factory = { context ->\n            object : AbstractComposeView(context) {\n                @Composable\n                override fun Content() {\n                    currentContentUnderTest()\n                }\n\n                override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets {\n                    children.forEach {\n                        it.dispatchApplyWindowInsets(\n                            WindowInsets(currentWindowInsets.toWindowInsets()),\n                        )\n                    }\n                    return WindowInsetsCompat.CONSUMED.toWindowInsets()!!\n                }\n\n                /**\n                 * Deprecated, but intercept the `requestApplyInsets` call via the deprecated\n                 * method.\n                 */\n                @Deprecated(\"Deprecated in Java\")\n                override fun requestFitSystemWindows() {\n                    dispatchApplyWindowInsets(WindowInsets(currentWindowInsets.toWindowInsets()!!))\n                }\n            }\n        },\n        update = { with(currentWindowInsets) { it.requestApplyInsets() } },\n    )\n}\n"
  },
  {
    "path": "app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.ui\n\nimport androidx.compose.material3.adaptive.Posture\nimport androidx.compose.material3.adaptive.WindowAdaptiveInfo\nimport androidx.compose.runtime.CompositionLocalProvider\nimport androidx.compose.ui.platform.LocalInspectionMode\nimport androidx.compose.ui.test.DeviceConfigurationOverride\nimport androidx.compose.ui.test.ForcedSize\nimport androidx.compose.ui.test.junit4.createAndroidComposeRule\nimport androidx.compose.ui.test.onRoot\nimport androidx.compose.ui.unit.Dp\nimport androidx.compose.ui.unit.DpSize\nimport androidx.compose.ui.unit.dp\nimport androidx.window.core.layout.WindowSizeClass\nimport com.github.takahirom.roborazzi.captureRoboImage\nimport com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository\nimport com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor\nimport com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\nimport com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions\nimport com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity\nimport dagger.hilt.android.testing.HiltAndroidRule\nimport dagger.hilt.android.testing.HiltAndroidTest\nimport dagger.hilt.android.testing.HiltTestApplication\nimport kotlinx.coroutines.flow.first\nimport kotlinx.coroutines.runBlocking\nimport org.junit.Before\nimport org.junit.Rule\nimport org.junit.Test\nimport org.junit.runner.RunWith\nimport org.robolectric.RobolectricTestRunner\nimport org.robolectric.annotation.Config\nimport org.robolectric.annotation.GraphicsMode\nimport org.robolectric.annotation.LooperMode\nimport java.util.TimeZone\nimport javax.inject.Inject\n\n/**\n * Tests that the navigation UI is rendered correctly on different screen sizes.\n */\n@RunWith(RobolectricTestRunner::class)\n@GraphicsMode(GraphicsMode.Mode.NATIVE)\n// Configure Robolectric to use a very large screen size that can fit all of the test sizes.\n// This allows enough room to render the content under test without clipping or scaling.\n@Config(application = HiltTestApplication::class, qualifiers = \"w1000dp-h1000dp-480dpi\")\n@LooperMode(LooperMode.Mode.PAUSED)\n@HiltAndroidTest\nclass NiaAppScreenSizesScreenshotTests {\n\n    /**\n     * Manages the components' state and is used to perform injection on your test\n     */\n    @get:Rule(order = 0)\n    val hiltRule = HiltAndroidRule(this)\n\n    /**\n     * Use a test activity to set the content on.\n     */\n    @get:Rule(order = 1)\n    val composeTestRule = createAndroidComposeRule<HiltComponentActivity>()\n\n    @Inject\n    lateinit var networkMonitor: NetworkMonitor\n\n    @Inject\n    lateinit var timeZoneMonitor: TimeZoneMonitor\n\n    @Inject\n    lateinit var userDataRepository: UserDataRepository\n\n    @Inject\n    lateinit var topicsRepository: TopicsRepository\n\n    @Inject\n    lateinit var userNewsResourceRepository: UserNewsResourceRepository\n\n    @Before\n    fun setup() {\n        hiltRule.inject()\n\n        // Configure user data\n        runBlocking {\n            userDataRepository.setShouldHideOnboarding(true)\n\n            userDataRepository.setFollowedTopicIds(\n                setOf(topicsRepository.getTopics().first().first().id),\n            )\n        }\n    }\n\n    @Before\n    fun setTimeZone() {\n        // Make time zone deterministic in tests\n        TimeZone.setDefault(TimeZone.getTimeZone(\"UTC\"))\n    }\n\n    private fun testNiaAppScreenshotWithSize(width: Dp, height: Dp, screenshotName: String) {\n        composeTestRule.setContent {\n            CompositionLocalProvider(\n                LocalInspectionMode provides true,\n            ) {\n                DeviceConfigurationOverride(\n                    override = DeviceConfigurationOverride.ForcedSize(DpSize(width, height)),\n                ) {\n                    NiaTheme {\n                        val fakeAppState = rememberNiaAppState(\n                            networkMonitor = networkMonitor,\n                            userNewsResourceRepository = userNewsResourceRepository,\n                            timeZoneMonitor = timeZoneMonitor,\n                        )\n                        NiaApp(\n                            fakeAppState,\n                            windowAdaptiveInfo = WindowAdaptiveInfo(\n                                windowSizeClass = WindowSizeClass.compute(\n                                    width.value,\n                                    height.value,\n                                ),\n                                windowPosture = Posture(),\n                            ),\n                        )\n                    }\n                }\n            }\n        }\n\n        composeTestRule.onRoot()\n            .captureRoboImage(\n                \"src/testDemo/screenshots/$screenshotName.png\",\n                roborazziOptions = DefaultRoborazziOptions,\n            )\n    }\n\n    @Test\n    fun compactWidth_compactHeight_showsNavigationBar() {\n        testNiaAppScreenshotWithSize(\n            400.dp,\n            400.dp,\n            \"compactWidth_compactHeight_showsNavigationBar\",\n        )\n    }\n\n    @Test\n    fun mediumWidth_compactHeight_showsNavigationBar() {\n        testNiaAppScreenshotWithSize(\n            610.dp,\n            400.dp,\n            \"mediumWidth_compactHeight_showsNavigationBar\",\n        )\n    }\n\n    @Test\n    fun expandedWidth_compactHeight_showsNavigationBar() {\n        testNiaAppScreenshotWithSize(\n            900.dp,\n            400.dp,\n            \"expandedWidth_compactHeight_showsNavigationBar\",\n        )\n    }\n\n    @Test\n    fun compactWidth_mediumHeight_showsNavigationBar() {\n        testNiaAppScreenshotWithSize(\n            400.dp,\n            500.dp,\n            \"compactWidth_mediumHeight_showsNavigationBar\",\n        )\n    }\n\n    @Test\n    fun mediumWidth_mediumHeight_showsNavigationRail() {\n        testNiaAppScreenshotWithSize(\n            610.dp,\n            500.dp,\n            \"mediumWidth_mediumHeight_showsNavigationRail\",\n        )\n    }\n\n    @Test\n    fun expandedWidth_mediumHeight_showsNavigationRail() {\n        testNiaAppScreenshotWithSize(\n            900.dp,\n            500.dp,\n            \"expandedWidth_mediumHeight_showsNavigationRail\",\n        )\n    }\n\n    @Test\n    fun compactWidth_expandedHeight_showsNavigationBar() {\n        testNiaAppScreenshotWithSize(\n            400.dp,\n            1000.dp,\n            \"compactWidth_expandedHeight_showsNavigationBar\",\n        )\n    }\n\n    @Test\n    fun mediumWidth_expandedHeight_showsNavigationRail() {\n        testNiaAppScreenshotWithSize(\n            610.dp,\n            1000.dp,\n            \"mediumWidth_expandedHeight_showsNavigationRail\",\n        )\n    }\n\n    @Test\n    fun expandedWidth_expandedHeight_showsNavigationRail() {\n        testNiaAppScreenshotWithSize(\n            900.dp,\n            1000.dp,\n            \"expandedWidth_expandedHeight_showsNavigationRail\",\n        )\n    }\n}\n"
  },
  {
    "path": "app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.ui\n\nimport androidx.compose.runtime.remember\nimport androidx.compose.ui.test.junit4.createComposeRule\nimport androidx.navigation3.runtime.NavBackStack\nimport com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository\nimport com.google.samples.apps.nowinandroid.core.navigation.NavigationState\nimport com.google.samples.apps.nowinandroid.core.navigation.Navigator\nimport com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository\nimport com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository\nimport com.google.samples.apps.nowinandroid.core.testing.util.TestNetworkMonitor\nimport com.google.samples.apps.nowinandroid.core.testing.util.TestTimeZoneMonitor\nimport com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation.BookmarksNavKey\nimport com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouNavKey\nimport com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsNavKey\nimport dagger.hilt.android.testing.HiltAndroidTest\nimport dagger.hilt.android.testing.HiltTestApplication\nimport kotlinx.coroutines.flow.collect\nimport kotlinx.coroutines.launch\nimport kotlinx.coroutines.test.UnconfinedTestDispatcher\nimport kotlinx.coroutines.test.runTest\nimport kotlinx.datetime.TimeZone\nimport org.junit.Rule\nimport org.junit.Test\nimport org.junit.runner.RunWith\nimport org.robolectric.RobolectricTestRunner\nimport org.robolectric.annotation.Config\nimport kotlin.test.assertEquals\n\n/**\n * Tests [NiaAppState].\n */\n@RunWith(RobolectricTestRunner::class)\n@Config(application = HiltTestApplication::class)\n@HiltAndroidTest\nclass NiaAppStateTest {\n\n    @get:Rule\n    val composeTestRule = createComposeRule()\n\n    // Create the test dependencies.\n    private val networkMonitor = TestNetworkMonitor()\n\n    private val timeZoneMonitor = TestTimeZoneMonitor()\n\n    private val userNewsResourceRepository =\n        CompositeUserNewsResourceRepository(TestNewsRepository(), TestUserDataRepository())\n\n    // Subject under test.\n    private lateinit var state: NiaAppState\n\n    private fun testNavigationState() = NavigationState(\n        startKey = ForYouNavKey,\n        topLevelStack = NavBackStack(ForYouNavKey),\n        subStacks = mapOf(\n            ForYouNavKey to NavBackStack(ForYouNavKey),\n            BookmarksNavKey to NavBackStack(BookmarksNavKey),\n        ),\n    )\n\n    @Test\n    fun niaAppState_currentDestination() = runTest {\n        val navigationState = testNavigationState()\n        val navigator = Navigator(navigationState)\n\n        composeTestRule.setContent {\n            state = remember(navigationState) {\n                NiaAppState(\n                    coroutineScope = backgroundScope,\n                    networkMonitor = networkMonitor,\n                    userNewsResourceRepository = userNewsResourceRepository,\n                    timeZoneMonitor = timeZoneMonitor,\n                    navigationState = navigationState,\n                )\n            }\n        }\n\n        assertEquals(ForYouNavKey, state.navigationState.currentTopLevelKey)\n        assertEquals(ForYouNavKey, state.navigationState.currentKey)\n\n        // Navigate to another destination once\n        navigator.navigate(BookmarksNavKey)\n\n        composeTestRule.waitForIdle()\n\n        assertEquals(BookmarksNavKey, state.navigationState.currentTopLevelKey)\n        assertEquals(BookmarksNavKey, state.navigationState.currentKey)\n    }\n\n    @Test\n    fun niaAppState_destinations() = runTest {\n        composeTestRule.setContent {\n            state = rememberNiaAppState(\n                networkMonitor = networkMonitor,\n                userNewsResourceRepository = userNewsResourceRepository,\n                timeZoneMonitor = timeZoneMonitor,\n            )\n        }\n\n        val navigationState = state.navigationState\n\n        assertEquals(3, navigationState.topLevelKeys.size)\n        assertEquals(\n            setOf(ForYouNavKey, BookmarksNavKey, InterestsNavKey(null)),\n            navigationState.topLevelKeys,\n        )\n    }\n\n    @Test\n    fun niaAppState_whenNetworkMonitorIsOffline_StateIsOffline() = runTest(UnconfinedTestDispatcher()) {\n        composeTestRule.setContent {\n            state = NiaAppState(\n                coroutineScope = backgroundScope,\n                networkMonitor = networkMonitor,\n                userNewsResourceRepository = userNewsResourceRepository,\n                timeZoneMonitor = timeZoneMonitor,\n                navigationState = testNavigationState(),\n            )\n        }\n\n        backgroundScope.launch { state.isOffline.collect() }\n        networkMonitor.setConnected(false)\n        assertEquals(\n            true,\n            state.isOffline.value,\n        )\n    }\n\n    @Test\n    fun niaAppState_differentTZ_withTimeZoneMonitorChange() = runTest(UnconfinedTestDispatcher()) {\n        composeTestRule.setContent {\n            state = NiaAppState(\n                coroutineScope = backgroundScope,\n                networkMonitor = networkMonitor,\n                userNewsResourceRepository = userNewsResourceRepository,\n                timeZoneMonitor = timeZoneMonitor,\n                navigationState = testNavigationState(),\n            )\n        }\n        val changedTz = TimeZone.of(\"Europe/Prague\")\n        backgroundScope.launch { state.currentTimeZone.collect() }\n        timeZoneMonitor.setTimeZone(changedTz)\n        assertEquals(\n            changedTz,\n            state.currentTimeZone.value,\n        )\n    }\n}\n"
  },
  {
    "path": "app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/SnackbarInsetsScreenshotTests.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.ui\n\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.BoxWithConstraints\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.WindowInsets\nimport androidx.compose.foundation.layout.WindowInsetsSides\nimport androidx.compose.foundation.layout.fillMaxHeight\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.only\nimport androidx.compose.foundation.layout.safeDrawing\nimport androidx.compose.foundation.layout.windowInsetsBottomHeight\nimport androidx.compose.foundation.layout.windowInsetsEndWidth\nimport androidx.compose.foundation.layout.windowInsetsPadding\nimport androidx.compose.foundation.layout.windowInsetsStartWidth\nimport androidx.compose.foundation.layout.windowInsetsTopHeight\nimport androidx.compose.material3.SnackbarDuration.Indefinite\nimport androidx.compose.material3.SnackbarHostState\nimport androidx.compose.material3.adaptive.Posture\nimport androidx.compose.material3.adaptive.WindowAdaptiveInfo\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.CompositionLocalProvider\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.graphics.toAndroidRect\nimport androidx.compose.ui.platform.LocalDensity\nimport androidx.compose.ui.platform.LocalInspectionMode\nimport androidx.compose.ui.platform.testTag\nimport androidx.compose.ui.test.DeviceConfigurationOverride\nimport androidx.compose.ui.test.ForcedSize\nimport androidx.compose.ui.test.junit4.createAndroidComposeRule\nimport androidx.compose.ui.test.onNodeWithTag\nimport androidx.compose.ui.unit.Density\nimport androidx.compose.ui.unit.Dp\nimport androidx.compose.ui.unit.DpRect\nimport androidx.compose.ui.unit.DpSize\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.unit.roundToIntRect\nimport androidx.core.graphics.Insets\nimport androidx.core.view.WindowInsetsCompat\nimport androidx.window.core.layout.WindowSizeClass\nimport com.github.takahirom.roborazzi.captureRoboImage\nimport com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository\nimport com.google.samples.apps.nowinandroid.core.data.test.repository.FakeUserDataRepository\nimport com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor\nimport com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\nimport com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions\nimport com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation.LocalSnackbarHostState\nimport com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity\nimport dagger.hilt.android.testing.HiltAndroidRule\nimport dagger.hilt.android.testing.HiltAndroidTest\nimport dagger.hilt.android.testing.HiltTestApplication\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.flow.first\nimport kotlinx.coroutines.launch\nimport kotlinx.coroutines.runBlocking\nimport org.junit.Before\nimport org.junit.Rule\nimport org.junit.Test\nimport org.junit.runner.RunWith\nimport org.robolectric.RobolectricTestRunner\nimport org.robolectric.annotation.Config\nimport org.robolectric.annotation.GraphicsMode\nimport org.robolectric.annotation.LooperMode\nimport java.util.TimeZone\nimport javax.inject.Inject\n\n/**\n * Tests that the Snackbar is correctly displayed on different screen sizes.\n */\n@RunWith(RobolectricTestRunner::class)\n@GraphicsMode(GraphicsMode.Mode.NATIVE)\n// Configure Robolectric to use a very large screen size that can fit all of the test sizes.\n// This allows enough room to render the content under test without clipping or scaling.\n@Config(application = HiltTestApplication::class, qualifiers = \"w1000dp-h1000dp-480dpi\")\n@LooperMode(LooperMode.Mode.PAUSED)\n@HiltAndroidTest\nclass SnackbarInsetsScreenshotTests {\n\n    /**\n     * Manages the components' state and is used to perform injection on your test\n     */\n    @get:Rule(order = 0)\n    val hiltRule = HiltAndroidRule(this)\n\n    /**\n     * Use a test activity to set the content on.\n     */\n    @get:Rule(order = 1)\n    val composeTestRule = createAndroidComposeRule<HiltComponentActivity>()\n\n    @Inject\n    lateinit var networkMonitor: NetworkMonitor\n\n    @Inject\n    lateinit var timeZoneMonitor: TimeZoneMonitor\n\n    @Inject\n    lateinit var userDataRepository: FakeUserDataRepository\n\n    @Inject\n    lateinit var topicsRepository: TopicsRepository\n\n    @Inject\n    lateinit var userNewsResourceRepository: UserNewsResourceRepository\n\n    @Before\n    fun setup() {\n        hiltRule.inject()\n\n        // Configure user data\n        runBlocking {\n            userDataRepository.setShouldHideOnboarding(true)\n\n            userDataRepository.setFollowedTopicIds(\n                setOf(topicsRepository.getTopics().first().first().id),\n            )\n        }\n    }\n\n    @Before\n    fun setTimeZone() {\n        // Make time zone deterministic in tests\n        TimeZone.setDefault(TimeZone.getTimeZone(\"UTC\"))\n    }\n\n    @Test\n    fun phone_noSnackbar() {\n        testSnackbarScreenshotWithSize(\n            400.dp,\n            500.dp,\n            \"insets_snackbar_compact_medium_noSnackbar\",\n            action = { },\n        )\n    }\n\n    @Test\n    fun snackbarShown_phone() {\n        testSnackbarScreenshotWithSize(\n            400.dp,\n            500.dp,\n            \"insets_snackbar_compact_medium\",\n        ) { snackbarHostState ->\n            snackbarHostState.showSnackbar(\n                \"This is a test snackbar message\",\n                actionLabel = \"Action Label\",\n                duration = Indefinite,\n            )\n        }\n    }\n\n    @Test\n    fun snackbarShown_foldable() {\n        testSnackbarScreenshotWithSize(\n            600.dp,\n            600.dp,\n            \"insets_snackbar_medium_medium\",\n        ) { snackbarHostState ->\n            snackbarHostState.showSnackbar(\n                \"This is a test snackbar message\",\n                actionLabel = \"Action Label\",\n                duration = Indefinite,\n            )\n        }\n    }\n\n    @Test\n    fun snackbarShown_tablet() {\n        testSnackbarScreenshotWithSize(\n            900.dp,\n            900.dp,\n            \"insets_snackbar_expanded_expanded\",\n        ) { snackbarHostState ->\n            snackbarHostState.showSnackbar(\n                \"This is a test snackbar message\",\n                actionLabel = \"Action Label\",\n                duration = Indefinite,\n            )\n        }\n    }\n\n    private fun testSnackbarScreenshotWithSize(\n        width: Dp,\n        height: Dp,\n        screenshotName: String,\n        action: suspend (snackbarHostState: SnackbarHostState) -> Unit,\n    ) {\n        lateinit var scope: CoroutineScope\n        val snackbarHostState = SnackbarHostState()\n        composeTestRule.setContent {\n            CompositionLocalProvider(\n                // Replaces images with placeholders\n                LocalInspectionMode provides true,\n                LocalSnackbarHostState provides snackbarHostState,\n            ) {\n                scope = rememberCoroutineScope()\n\n                DeviceConfigurationOverride(\n                    DeviceConfigurationOverride.ForcedSize(DpSize(width, height)),\n                ) {\n                    DeviceConfigurationOverride(\n                        DeviceConfigurationOverride.WindowInsets(\n                            WindowInsetsCompat.Builder()\n                                .setInsets(\n                                    WindowInsetsCompat.Type.statusBars(),\n                                    DpRect(\n                                        left = 0.dp,\n                                        top = 64.dp,\n                                        right = 0.dp,\n                                        bottom = 0.dp,\n                                    ).toInsets(),\n                                )\n                                .setInsets(\n                                    WindowInsetsCompat.Type.navigationBars(),\n                                    DpRect(\n                                        left = 64.dp,\n                                        top = 0.dp,\n                                        right = 64.dp,\n                                        bottom = 64.dp,\n                                    ).toInsets(),\n                                )\n                                .build(),\n                        ),\n                    ) {\n                        BoxWithConstraints(Modifier.testTag(\"root\")) {\n                            NiaTheme {\n                                val appState = rememberNiaAppState(\n                                    networkMonitor = networkMonitor,\n                                    userNewsResourceRepository = userNewsResourceRepository,\n                                    timeZoneMonitor = timeZoneMonitor,\n                                )\n                                NiaApp(\n                                    appState = appState,\n                                    showSettingsDialog = false,\n                                    onSettingsDismissed = {},\n                                    onTopAppBarActionClick = {},\n                                    windowAdaptiveInfo = WindowAdaptiveInfo(\n                                        windowSizeClass = WindowSizeClass.compute(\n                                            maxWidth.value,\n                                            maxHeight.value,\n                                        ),\n                                        windowPosture = Posture(),\n                                    ),\n                                )\n                                DebugVisibleWindowInsets()\n                            }\n                        }\n                    }\n                }\n            }\n        }\n\n        scope.launch {\n            action(snackbarHostState)\n        }\n\n        composeTestRule.onNodeWithTag(\"root\")\n            .captureRoboImage(\n                \"src/testDemo/screenshots/$screenshotName.png\",\n                roborazziOptions = DefaultRoborazziOptions,\n            )\n    }\n}\n\n@Composable\nfun DebugVisibleWindowInsets(\n    modifier: Modifier = Modifier,\n    debugColor: Color = Color.Magenta.copy(alpha = 0.5f),\n) {\n    Box(modifier = modifier.fillMaxSize()) {\n        Spacer(\n            modifier = Modifier\n                .align(Alignment.CenterStart)\n                .fillMaxHeight()\n                .windowInsetsStartWidth(WindowInsets.safeDrawing)\n                .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical))\n                .background(debugColor),\n        )\n        Spacer(\n            modifier = Modifier\n                .align(Alignment.CenterEnd)\n                .fillMaxHeight()\n                .windowInsetsEndWidth(WindowInsets.safeDrawing)\n                .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical))\n                .background(debugColor),\n        )\n        Spacer(\n            modifier = Modifier\n                .align(Alignment.TopCenter)\n                .fillMaxWidth()\n                .windowInsetsTopHeight(WindowInsets.safeDrawing)\n                .background(debugColor),\n        )\n        Spacer(\n            modifier = Modifier\n                .align(Alignment.BottomCenter)\n                .fillMaxWidth()\n                .windowInsetsBottomHeight(WindowInsets.safeDrawing)\n                .background(debugColor),\n        )\n    }\n}\n\n@Composable\nprivate fun DpRect.toInsets() = toInsets(LocalDensity.current)\n\nprivate fun DpRect.toInsets(density: Density) =\n    Insets.of(with(density) { toRect() }.roundToIntRect().toAndroidRect())\n"
  },
  {
    "path": "app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/SnackbarScreenshotTests.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.ui\n\nimport androidx.compose.foundation.layout.BoxWithConstraints\nimport androidx.compose.material3.SnackbarDuration.Indefinite\nimport androidx.compose.material3.SnackbarHostState\nimport androidx.compose.material3.adaptive.Posture\nimport androidx.compose.material3.adaptive.WindowAdaptiveInfo\nimport androidx.compose.runtime.CompositionLocalProvider\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.ui.platform.LocalInspectionMode\nimport androidx.compose.ui.test.DeviceConfigurationOverride\nimport androidx.compose.ui.test.ForcedSize\nimport androidx.compose.ui.test.junit4.createAndroidComposeRule\nimport androidx.compose.ui.test.onRoot\nimport androidx.compose.ui.unit.Dp\nimport androidx.compose.ui.unit.DpSize\nimport androidx.compose.ui.unit.dp\nimport androidx.window.core.layout.WindowSizeClass\nimport com.github.takahirom.roborazzi.captureRoboImage\nimport com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository\nimport com.google.samples.apps.nowinandroid.core.data.test.repository.FakeUserDataRepository\nimport com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor\nimport com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\nimport com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions\nimport com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation.LocalSnackbarHostState\nimport com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity\nimport dagger.hilt.android.testing.HiltAndroidRule\nimport dagger.hilt.android.testing.HiltAndroidTest\nimport dagger.hilt.android.testing.HiltTestApplication\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.flow.first\nimport kotlinx.coroutines.launch\nimport kotlinx.coroutines.runBlocking\nimport org.junit.Before\nimport org.junit.Rule\nimport org.junit.Test\nimport org.junit.runner.RunWith\nimport org.robolectric.RobolectricTestRunner\nimport org.robolectric.annotation.Config\nimport org.robolectric.annotation.GraphicsMode\nimport org.robolectric.annotation.LooperMode\nimport java.util.TimeZone\nimport javax.inject.Inject\n\n/**\n * Tests that the Snackbar is correctly displayed on different screen sizes.\n */\n@RunWith(RobolectricTestRunner::class)\n@GraphicsMode(GraphicsMode.Mode.NATIVE)\n// Configure Robolectric to use a very large screen size that can fit all of the test sizes.\n// This allows enough room to render the content under test without clipping or scaling.\n@Config(application = HiltTestApplication::class, qualifiers = \"w1000dp-h1000dp-480dpi\")\n@LooperMode(LooperMode.Mode.PAUSED)\n@HiltAndroidTest\nclass SnackbarScreenshotTests {\n\n    /**\n     * Manages the components' state and is used to perform injection on your test\n     */\n    @get:Rule(order = 0)\n    val hiltRule = HiltAndroidRule(this)\n\n    /**\n     * Use a test activity to set the content on.\n     */\n    @get:Rule(order = 1)\n    val composeTestRule = createAndroidComposeRule<HiltComponentActivity>()\n\n    @Inject\n    lateinit var networkMonitor: NetworkMonitor\n\n    @Inject\n    lateinit var timeZoneMonitor: TimeZoneMonitor\n\n    @Inject\n    lateinit var userDataRepository: FakeUserDataRepository\n\n    @Inject\n    lateinit var topicsRepository: TopicsRepository\n\n    @Inject\n    lateinit var userNewsResourceRepository: UserNewsResourceRepository\n\n    @Before\n    fun setup() {\n        hiltRule.inject()\n\n        // Configure user data\n        runBlocking {\n            userDataRepository.setShouldHideOnboarding(true)\n\n            userDataRepository.setFollowedTopicIds(\n                setOf(topicsRepository.getTopics().first().first().id),\n            )\n        }\n    }\n\n    @Before\n    fun setTimeZone() {\n        // Make time zone deterministic in tests\n        TimeZone.setDefault(TimeZone.getTimeZone(\"UTC\"))\n    }\n\n    @Test\n    fun phone_noSnackbar() {\n        testSnackbarScreenshotWithSize(\n            400.dp,\n            500.dp,\n            \"snackbar_compact_medium_noSnackbar\",\n            action = { },\n        )\n    }\n\n    @Test\n    fun snackbarShown_phone() {\n        testSnackbarScreenshotWithSize(\n            400.dp,\n            500.dp,\n            \"snackbar_compact_medium\",\n        ) { snackbarHostState ->\n            snackbarHostState.showSnackbar(\n                \"This is a test snackbar message\",\n                actionLabel = \"Action Label\",\n                duration = Indefinite,\n            )\n        }\n    }\n\n    @Test\n    fun snackbarShown_foldable() {\n        testSnackbarScreenshotWithSize(\n            600.dp,\n            600.dp,\n            \"snackbar_medium_medium\",\n        ) { snackbarHostState ->\n            snackbarHostState.showSnackbar(\n                \"This is a test snackbar message\",\n                actionLabel = \"Action Label\",\n                duration = Indefinite,\n            )\n        }\n    }\n\n    @Test\n    fun snackbarShown_tablet() {\n        testSnackbarScreenshotWithSize(\n            900.dp,\n            900.dp,\n            \"snackbar_expanded_expanded\",\n        ) { snackbarHostState ->\n            snackbarHostState.showSnackbar(\n                \"This is a test snackbar message\",\n                actionLabel = \"Action Label\",\n                duration = Indefinite,\n            )\n        }\n    }\n\n    private fun testSnackbarScreenshotWithSize(\n        width: Dp,\n        height: Dp,\n        screenshotName: String,\n        action: suspend (snackbarHostState: SnackbarHostState) -> Unit,\n    ) {\n        lateinit var scope: CoroutineScope\n        val snackbarHostState = SnackbarHostState()\n        composeTestRule.setContent {\n            CompositionLocalProvider(\n                // Replaces images with placeholders\n                LocalInspectionMode provides true,\n                LocalSnackbarHostState provides snackbarHostState,\n\n            ) {\n                scope = rememberCoroutineScope()\n\n                DeviceConfigurationOverride(\n                    DeviceConfigurationOverride.ForcedSize(DpSize(width, height)),\n                ) {\n                    BoxWithConstraints {\n                        NiaTheme {\n                            val appState = rememberNiaAppState(\n                                networkMonitor = networkMonitor,\n                                userNewsResourceRepository = userNewsResourceRepository,\n                                timeZoneMonitor = timeZoneMonitor,\n                            )\n                            NiaApp(\n                                appState = appState,\n                                showSettingsDialog = false,\n                                onSettingsDismissed = {},\n                                onTopAppBarActionClick = {},\n                                windowAdaptiveInfo = WindowAdaptiveInfo(\n                                    windowSizeClass = WindowSizeClass.compute(\n                                        maxWidth.value,\n                                        maxHeight.value,\n                                    ),\n                                    windowPosture = Posture(),\n                                ),\n                            )\n                        }\n                    }\n                }\n            }\n        }\n\n        scope.launch {\n            action(snackbarHostState)\n        }\n\n        composeTestRule.onRoot()\n            .captureRoboImage(\n                \"src/testDemo/screenshots/$screenshotName.png\",\n                roborazziOptions = DefaultRoborazziOptions,\n            )\n    }\n}\n"
  },
  {
    "path": "app/src/testDemo/resources/robolectric.properties",
    "content": "#\n# Copyright 2025 The Android Open Source Project\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     https://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nsdk = 35"
  },
  {
    "path": "app-nia-catalog/.gitignore",
    "content": "/build"
  },
  {
    "path": "app-nia-catalog/README.md",
    "content": "# `:app-nia-catalog`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :core\n    direction TB\n    :core:analytics[analytics]:::android-library\n    :core:designsystem[designsystem]:::android-library\n    :core:model[model]:::jvm-library\n    :core:ui[ui]:::android-library\n  end\n  :app-nia-catalog[app-nia-catalog]:::android-application\n\n  :app-nia-catalog -.-> :core:designsystem\n  :app-nia-catalog -.-> :core:ui\n  :core:ui --> :core:analytics\n  :core:ui --> :core:designsystem\n  :core:ui --> :core:model\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "app-nia-catalog/build.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nimport com.google.samples.apps.nowinandroid.FlavorDimension\nimport com.google.samples.apps.nowinandroid.NiaFlavor\n\n/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nplugins {\n    alias(libs.plugins.nowinandroid.android.application)\n    alias(libs.plugins.nowinandroid.android.application.compose)\n}\n\nandroid {\n    defaultConfig {\n        applicationId = \"com.google.samples.apps.niacatalog\"\n        versionCode = 1\n        versionName = \"0.0.1\" // X.Y.Z; X = Major, Y = minor, Z = Patch level\n\n        // The UI catalog does not depend on content from the app, however, it depends on modules\n        // which do, so we must specify a default value for the contentType dimension.\n        missingDimensionStrategy(FlavorDimension.contentType.name, NiaFlavor.demo.name)\n    }\n\n    packaging {\n        resources {\n            excludes.add(\"/META-INF/{AL2.0,LGPL2.1}\")\n        }\n    }\n    namespace = \"com.google.samples.apps.niacatalog\"\n\n    buildTypes {\n        release {\n            // To publish on the Play store a private signing key is required, but to allow anyone\n            // who clones the code to sign and run the release variant, use the debug signing key.\n            // TODO: Abstract the signing configuration to a separate file to avoid hardcoding this.\n            signingConfig = signingConfigs.named(\"debug\").get()\n        }\n    }\n}\n\ndependencies {\n    implementation(libs.androidx.activity.compose)\n\n    implementation(projects.core.designsystem)\n    implementation(projects.core.ui)\n}\n\ndependencyGuard {\n    configuration(\"releaseRuntimeClasspath\")\n}\n"
  },
  {
    "path": "app-nia-catalog/dependencies/releaseRuntimeClasspath.txt",
    "content": "androidx.activity:activity-compose:1.9.3\nandroidx.activity:activity-ktx:1.9.3\nandroidx.activity:activity:1.9.3\nandroidx.annotation:annotation-experimental:1.5.1\nandroidx.annotation:annotation-jvm:1.9.1\nandroidx.annotation:annotation:1.9.1\nandroidx.appcompat:appcompat-resources:1.6.1\nandroidx.arch.core:core-common:2.2.0\nandroidx.arch.core:core-runtime:2.2.0\nandroidx.autofill:autofill:1.0.0\nandroidx.browser:browser:1.8.0\nandroidx.collection:collection-jvm:1.5.0\nandroidx.collection:collection-ktx:1.5.0\nandroidx.collection:collection:1.5.0\nandroidx.compose.animation:animation-android:1.10.0-alpha04\nandroidx.compose.animation:animation-core-android:1.10.0-alpha04\nandroidx.compose.animation:animation-core:1.10.0-alpha04\nandroidx.compose.animation:animation:1.10.0-alpha04\nandroidx.compose.foundation:foundation-android:1.10.0-alpha04\nandroidx.compose.foundation:foundation-layout-android:1.10.0-alpha04\nandroidx.compose.foundation:foundation-layout:1.10.0-alpha04\nandroidx.compose.foundation:foundation:1.10.0-alpha04\nandroidx.compose.material3.adaptive:adaptive-android:1.2.0-beta03\nandroidx.compose.material3.adaptive:adaptive:1.2.0-beta03\nandroidx.compose.material3:material3-adaptive-navigation-suite-android:1.5.0-alpha04\nandroidx.compose.material3:material3-adaptive-navigation-suite:1.5.0-alpha04\nandroidx.compose.material3:material3-android:1.5.0-alpha04\nandroidx.compose.material3:material3:1.5.0-alpha04\nandroidx.compose.material:material-icons-core-android:1.7.8\nandroidx.compose.material:material-icons-core:1.7.8\nandroidx.compose.material:material-icons-extended-android:1.7.8\nandroidx.compose.material:material-icons-extended:1.7.8\nandroidx.compose.material:material-ripple-android:1.10.0-alpha04\nandroidx.compose.material:material-ripple:1.10.0-alpha04\nandroidx.compose.runtime:runtime-android:1.10.0-alpha04\nandroidx.compose.runtime:runtime-annotation-android:1.10.0-alpha04\nandroidx.compose.runtime:runtime-annotation:1.10.0-alpha04\nandroidx.compose.runtime:runtime-retain-android:1.10.0-alpha04\nandroidx.compose.runtime:runtime-retain:1.10.0-alpha04\nandroidx.compose.runtime:runtime-saveable-android:1.10.0-alpha04\nandroidx.compose.runtime:runtime-saveable:1.10.0-alpha04\nandroidx.compose.runtime:runtime:1.10.0-alpha04\nandroidx.compose.ui:ui-android:1.10.0-alpha04\nandroidx.compose.ui:ui-geometry-android:1.10.0-alpha04\nandroidx.compose.ui:ui-geometry:1.10.0-alpha04\nandroidx.compose.ui:ui-graphics-android:1.10.0-alpha04\nandroidx.compose.ui:ui-graphics:1.10.0-alpha04\nandroidx.compose.ui:ui-text-android:1.10.0-alpha04\nandroidx.compose.ui:ui-text:1.10.0-alpha04\nandroidx.compose.ui:ui-tooling-preview-android:1.10.0-alpha04\nandroidx.compose.ui:ui-tooling-preview:1.10.0-alpha04\nandroidx.compose.ui:ui-unit-android:1.10.0-alpha04\nandroidx.compose.ui:ui-unit:1.10.0-alpha04\nandroidx.compose.ui:ui-util-android:1.10.0-alpha04\nandroidx.compose.ui:ui-util:1.10.0-alpha04\nandroidx.compose.ui:ui:1.10.0-alpha04\nandroidx.compose:compose-bom-alpha:2025.09.01\nandroidx.concurrent:concurrent-futures:1.1.0\nandroidx.core:core-ktx:1.16.0\nandroidx.core:core-viewtree:1.0.0\nandroidx.core:core:1.16.0\nandroidx.customview:customview-poolingcontainer:1.0.0\nandroidx.customview:customview:1.0.0\nandroidx.documentfile:documentfile:1.0.0\nandroidx.dynamicanimation:dynamicanimation:1.0.0\nandroidx.emoji2:emoji2:1.4.0\nandroidx.exifinterface:exifinterface:1.3.7\nandroidx.fragment:fragment:1.5.1\nandroidx.graphics:graphics-path:1.0.1\nandroidx.graphics:graphics-shapes-android:1.0.1\nandroidx.graphics:graphics-shapes:1.0.1\nandroidx.interpolator:interpolator:1.0.0\nandroidx.legacy:legacy-support-core-utils:1.0.0\nandroidx.lifecycle:lifecycle-common-java8:2.9.4\nandroidx.lifecycle:lifecycle-common-jvm:2.9.4\nandroidx.lifecycle:lifecycle-common:2.9.4\nandroidx.lifecycle:lifecycle-livedata-core-ktx:2.9.4\nandroidx.lifecycle:lifecycle-livedata-core:2.9.4\nandroidx.lifecycle:lifecycle-livedata:2.9.4\nandroidx.lifecycle:lifecycle-process:2.9.4\nandroidx.lifecycle:lifecycle-runtime-android:2.9.4\nandroidx.lifecycle:lifecycle-runtime-compose-android:2.9.4\nandroidx.lifecycle:lifecycle-runtime-compose:2.9.4\nandroidx.lifecycle:lifecycle-runtime-ktx-android:2.9.4\nandroidx.lifecycle:lifecycle-runtime-ktx:2.9.4\nandroidx.lifecycle:lifecycle-runtime:2.9.4\nandroidx.lifecycle:lifecycle-viewmodel-android:2.9.4\nandroidx.lifecycle:lifecycle-viewmodel-ktx:2.9.4\nandroidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.9.4\nandroidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.4\nandroidx.lifecycle:lifecycle-viewmodel:2.9.4\nandroidx.loader:loader:1.0.0\nandroidx.localbroadcastmanager:localbroadcastmanager:1.0.0\nandroidx.metrics:metrics-performance:1.0.0-beta01\nandroidx.print:print:1.0.0\nandroidx.profileinstaller:profileinstaller:1.4.0\nandroidx.savedstate:savedstate-android:1.3.2\nandroidx.savedstate:savedstate-compose-android:1.3.2\nandroidx.savedstate:savedstate-compose:1.3.2\nandroidx.savedstate:savedstate-ktx:1.3.2\nandroidx.savedstate:savedstate:1.3.2\nandroidx.startup:startup-runtime:1.1.1\nandroidx.tracing:tracing-ktx:1.3.0-alpha02\nandroidx.tracing:tracing:1.3.0-alpha02\nandroidx.transition:transition:1.6.0\nandroidx.vectordrawable:vectordrawable-animated:1.1.0\nandroidx.vectordrawable:vectordrawable:1.1.0\nandroidx.versionedparcelable:versionedparcelable:1.1.1\nandroidx.viewpager:viewpager:1.0.0\nandroidx.window:window-core-android:1.4.0\nandroidx.window:window-core:1.4.0\nandroidx.window:window:1.4.0\ncom.google.accompanist:accompanist-drawablepainter:0.32.0\ncom.google.code.findbugs:jsr305:3.0.2\ncom.google.dagger:dagger-lint-aar:2.59\ncom.google.dagger:dagger:2.59\ncom.google.dagger:hilt-android:2.59\ncom.google.dagger:hilt-core:2.59\ncom.google.guava:listenablefuture:1.0\ncom.squareup.okhttp3:okhttp:4.12.0\ncom.squareup.okio:okio-jvm:3.9.0\ncom.squareup.okio:okio:3.9.0\nio.coil-kt:coil-base:2.7.0\nio.coil-kt:coil-compose-base:2.7.0\nio.coil-kt:coil-compose:2.7.0\nio.coil-kt:coil:2.7.0\njakarta.inject:jakarta.inject-api:2.0.1\njavax.inject:javax.inject:1\norg.jetbrains.kotlin:kotlin-stdlib-common:2.3.0\norg.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0\norg.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0\norg.jetbrains.kotlin:kotlin-stdlib:2.3.0\norg.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0\norg.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0\norg.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0\norg.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0\norg.jetbrains.kotlinx:kotlinx-datetime-jvm:0.6.1\norg.jetbrains.kotlinx:kotlinx-datetime:0.6.1\norg.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3\norg.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3\norg.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3\norg.jetbrains:annotations:23.0.0\norg.jspecify:jspecify:1.0.0\n"
  },
  {
    "path": "app-nia-catalog/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n\n    <application\n        android:allowBackup=\"true\"\n        android:icon=\"@mipmap/ic_launcher\"\n        android:label=\"@string/app_name\"\n        android:roundIcon=\"@mipmap/ic_launcher_round\"\n        android:supportsRtl=\"true\"\n        android:theme=\"@style/Theme.NiaCatalog\">\n        <activity\n            android:name=\"com.google.samples.apps.niacatalog.NiaCatalogActivity\"\n            android:exported=\"true\"\n            android:label=\"@string/app_name\"\n            android:theme=\"@style/Theme.NiaCatalog\">\n            <intent-filter>\n                <action android:name=\"android.intent.action.MAIN\" />\n\n                <category android:name=\"android.intent.category.LAUNCHER\" />\n            </intent-filter>\n        </activity>\n    </application>\n\n</manifest>\n"
  },
  {
    "path": "app-nia-catalog/src/main/kotlin/com/google/samples/apps/niacatalog/NiaCatalogActivity.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.niacatalog\n\nimport android.os.Bundle\nimport androidx.activity.ComponentActivity\nimport androidx.activity.compose.setContent\nimport androidx.core.view.WindowCompat\nimport com.google.samples.apps.niacatalog.ui.NiaCatalog\n\nclass NiaCatalogActivity : ComponentActivity() {\n    override fun onCreate(savedInstanceState: Bundle?) {\n        super.onCreate(savedInstanceState)\n\n        WindowCompat.setDecorFitsSystemWindows(window, false)\n\n        setContent { NiaCatalog() }\n    }\n}\n"
  },
  {
    "path": "app-nia-catalog/src/main/kotlin/com/google/samples/apps/niacatalog/ui/Catalog.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.niacatalog.ui\n\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.ExperimentalLayoutApi\nimport androidx.compose.foundation.layout.FlowRow\nimport androidx.compose.foundation.layout.WindowInsets\nimport androidx.compose.foundation.layout.add\nimport androidx.compose.foundation.layout.asPaddingValues\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.systemBars\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Surface\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableIntStateOf\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.saveable.rememberSaveable\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.unit.dp\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaButton\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilterChip\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBar\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBarItem\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaOutlinedButton\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTab\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTabRow\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTextButton\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopicTag\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaViewToggleButton\nimport com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\n\n/**\n * Now in Android component catalog.\n */\n@OptIn(ExperimentalLayoutApi::class)\n@Composable\nfun NiaCatalog() {\n    NiaTheme {\n        Surface {\n            val contentPadding = WindowInsets\n                .systemBars\n                .add(WindowInsets(left = 16.dp, top = 16.dp, right = 16.dp, bottom = 16.dp))\n                .asPaddingValues()\n            LazyColumn(\n                modifier = Modifier.fillMaxSize(),\n                contentPadding = contentPadding,\n                verticalArrangement = Arrangement.spacedBy(16.dp),\n            ) {\n                item {\n                    Text(\n                        text = \"NiA Catalog\",\n                        style = MaterialTheme.typography.headlineSmall,\n                    )\n                }\n                item { Text(\"Buttons\", Modifier.padding(top = 16.dp)) }\n                item {\n                    FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) {\n                        NiaButton(onClick = {}) {\n                            Text(text = \"Enabled\")\n                        }\n                        NiaOutlinedButton(onClick = {}) {\n                            Text(text = \"Enabled\")\n                        }\n                        NiaTextButton(onClick = {}) {\n                            Text(text = \"Enabled\")\n                        }\n                    }\n                }\n                item { Text(\"Disabled buttons\", Modifier.padding(top = 16.dp)) }\n                item {\n                    FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) {\n                        NiaButton(\n                            onClick = {},\n                            enabled = false,\n                        ) {\n                            Text(text = \"Disabled\")\n                        }\n                        NiaOutlinedButton(\n                            onClick = {},\n                            enabled = false,\n                        ) {\n                            Text(text = \"Disabled\")\n                        }\n                        NiaTextButton(\n                            onClick = {},\n                            enabled = false,\n                        ) {\n                            Text(text = \"Disabled\")\n                        }\n                    }\n                }\n                item { Text(\"Buttons with leading icons\", Modifier.padding(top = 16.dp)) }\n                item {\n                    FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) {\n                        NiaButton(\n                            onClick = {},\n                            text = { Text(text = \"Enabled\") },\n                            leadingIcon = {\n                                Icon(imageVector = NiaIcons.Add, contentDescription = null)\n                            },\n                        )\n                        NiaOutlinedButton(\n                            onClick = {},\n                            text = { Text(text = \"Enabled\") },\n                            leadingIcon = {\n                                Icon(imageVector = NiaIcons.Add, contentDescription = null)\n                            },\n                        )\n                        NiaTextButton(\n                            onClick = {},\n                            text = { Text(text = \"Enabled\") },\n                            leadingIcon = {\n                                Icon(imageVector = NiaIcons.Add, contentDescription = null)\n                            },\n                        )\n                    }\n                }\n                item { Text(\"Disabled buttons with leading icons\", Modifier.padding(top = 16.dp)) }\n                item {\n                    FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) {\n                        NiaButton(\n                            onClick = {},\n                            enabled = false,\n                            text = { Text(text = \"Disabled\") },\n                            leadingIcon = {\n                                Icon(imageVector = NiaIcons.Add, contentDescription = null)\n                            },\n                        )\n                        NiaOutlinedButton(\n                            onClick = {},\n                            enabled = false,\n                            text = { Text(text = \"Disabled\") },\n                            leadingIcon = {\n                                Icon(imageVector = NiaIcons.Add, contentDescription = null)\n                            },\n                        )\n                        NiaTextButton(\n                            onClick = {},\n                            enabled = false,\n                            text = { Text(text = \"Disabled\") },\n                            leadingIcon = {\n                                Icon(imageVector = NiaIcons.Add, contentDescription = null)\n                            },\n                        )\n                    }\n                }\n                item { Text(\"Dropdown menus\", Modifier.padding(top = 16.dp)) }\n                item { Text(\"Chips\", Modifier.padding(top = 16.dp)) }\n                item {\n                    FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) {\n                        var firstChecked by rememberSaveable { mutableStateOf(false) }\n                        NiaFilterChip(\n                            selected = firstChecked,\n                            onSelectedChange = { checked -> firstChecked = checked },\n                            label = { Text(text = \"Enabled\") },\n                        )\n                        var secondChecked by rememberSaveable { mutableStateOf(true) }\n                        NiaFilterChip(\n                            selected = secondChecked,\n                            onSelectedChange = { checked -> secondChecked = checked },\n                            label = { Text(text = \"Enabled\") },\n                        )\n                        NiaFilterChip(\n                            selected = false,\n                            onSelectedChange = {},\n                            enabled = false,\n                            label = { Text(text = \"Disabled\") },\n                        )\n                        NiaFilterChip(\n                            selected = true,\n                            onSelectedChange = {},\n                            enabled = false,\n                            label = { Text(text = \"Disabled\") },\n                        )\n                    }\n                }\n                item { Text(\"Icon buttons\", Modifier.padding(top = 16.dp)) }\n                item {\n                    FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) {\n                        var firstChecked by rememberSaveable { mutableStateOf(false) }\n                        NiaIconToggleButton(\n                            checked = firstChecked,\n                            onCheckedChange = { checked -> firstChecked = checked },\n                            icon = {\n                                Icon(\n                                    imageVector = NiaIcons.BookmarkBorder,\n                                    contentDescription = null,\n                                )\n                            },\n                            checkedIcon = {\n                                Icon(\n                                    imageVector = NiaIcons.Bookmark,\n                                    contentDescription = null,\n                                )\n                            },\n                        )\n                        var secondChecked by rememberSaveable { mutableStateOf(true) }\n                        NiaIconToggleButton(\n                            checked = secondChecked,\n                            onCheckedChange = { checked -> secondChecked = checked },\n                            icon = {\n                                Icon(\n                                    imageVector = NiaIcons.BookmarkBorder,\n                                    contentDescription = null,\n                                )\n                            },\n                            checkedIcon = {\n                                Icon(\n                                    imageVector = NiaIcons.Bookmark,\n                                    contentDescription = null,\n                                )\n                            },\n                        )\n                        NiaIconToggleButton(\n                            checked = false,\n                            onCheckedChange = {},\n                            icon = {\n                                Icon(\n                                    imageVector = NiaIcons.BookmarkBorder,\n                                    contentDescription = null,\n                                )\n                            },\n                            checkedIcon = {\n                                Icon(\n                                    imageVector = NiaIcons.Bookmark,\n                                    contentDescription = null,\n                                )\n                            },\n                            enabled = false,\n                        )\n                        NiaIconToggleButton(\n                            checked = true,\n                            onCheckedChange = {},\n                            icon = {\n                                Icon(\n                                    imageVector = NiaIcons.BookmarkBorder,\n                                    contentDescription = null,\n                                )\n                            },\n                            checkedIcon = {\n                                Icon(\n                                    imageVector = NiaIcons.Bookmark,\n                                    contentDescription = null,\n                                )\n                            },\n                            enabled = false,\n                        )\n                    }\n                }\n                item { Text(\"View toggle\", Modifier.padding(top = 16.dp)) }\n                item {\n                    FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) {\n                        var firstExpanded by rememberSaveable { mutableStateOf(false) }\n                        NiaViewToggleButton(\n                            expanded = firstExpanded,\n                            onExpandedChange = { expanded -> firstExpanded = expanded },\n                            compactText = { Text(text = \"Compact view\") },\n                            expandedText = { Text(text = \"Expanded view\") },\n                        )\n                        var secondExpanded by rememberSaveable { mutableStateOf(true) }\n                        NiaViewToggleButton(\n                            expanded = secondExpanded,\n                            onExpandedChange = { expanded -> secondExpanded = expanded },\n                            compactText = { Text(text = \"Compact view\") },\n                            expandedText = { Text(text = \"Expanded view\") },\n                        )\n                        NiaViewToggleButton(\n                            expanded = false,\n                            onExpandedChange = {},\n                            compactText = { Text(text = \"Disabled\") },\n                            expandedText = { Text(text = \"Disabled\") },\n                            enabled = false,\n                        )\n                    }\n                }\n                item { Text(\"Tags\", Modifier.padding(top = 16.dp)) }\n                item {\n                    FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) {\n                        NiaTopicTag(\n                            followed = true,\n                            onClick = {},\n                            text = { Text(text = \"Topic 1\".uppercase()) },\n                        )\n                        NiaTopicTag(\n                            followed = false,\n                            onClick = {},\n                            text = { Text(text = \"Topic 2\".uppercase()) },\n                        )\n                        NiaTopicTag(\n                            followed = false,\n                            onClick = {},\n                            text = { Text(text = \"Disabled\".uppercase()) },\n                            enabled = false,\n                        )\n                    }\n                }\n                item { Text(\"Tabs\", Modifier.padding(top = 16.dp)) }\n                item {\n                    var selectedTabIndex by rememberSaveable { mutableIntStateOf(0) }\n                    val titles = listOf(\"Topics\", \"People\")\n                    NiaTabRow(selectedTabIndex = selectedTabIndex) {\n                        titles.forEachIndexed { index, title ->\n                            NiaTab(\n                                selected = selectedTabIndex == index,\n                                onClick = { selectedTabIndex = index },\n                                text = { Text(text = title) },\n                            )\n                        }\n                    }\n                }\n                item { Text(\"Navigation\", Modifier.padding(top = 16.dp)) }\n                item {\n                    var selectedItem by rememberSaveable { mutableIntStateOf(0) }\n                    val items = listOf(\"For you\", \"Saved\", \"Interests\")\n                    val icons = listOf(\n                        NiaIcons.UpcomingBorder,\n                        NiaIcons.BookmarksBorder,\n                        NiaIcons.Grid3x3,\n                    )\n                    val selectedIcons = listOf(\n                        NiaIcons.Upcoming,\n                        NiaIcons.Bookmarks,\n                        NiaIcons.Grid3x3,\n                    )\n                    NiaNavigationBar {\n                        items.forEachIndexed { index, item ->\n                            NiaNavigationBarItem(\n                                icon = {\n                                    Icon(\n                                        imageVector = icons[index],\n                                        contentDescription = item,\n                                    )\n                                },\n                                selectedIcon = {\n                                    Icon(\n                                        imageVector = selectedIcons[index],\n                                        contentDescription = item,\n                                    )\n                                },\n                                label = { Text(item) },\n                                selected = selectedItem == index,\n                                onClick = { selectedItem = index },\n                            )\n                        }\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "app-nia-catalog/src/main/res/drawable/ic_launcher_background.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"108dp\"\n    android:height=\"108dp\"\n    android:viewportWidth=\"108\"\n    android:viewportHeight=\"108\">\n  <path\n      android:pathData=\"M0,0h108v108h-108z\"\n      android:fillColor=\"#FFFFFF\"/>\n</vector>\n"
  },
  {
    "path": "app-nia-catalog/src/main/res/drawable/ic_launcher_foreground.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"108dp\"\n    android:height=\"108dp\"\n    android:viewportWidth=\"108\"\n    android:viewportHeight=\"108\">\n  <path\n      android:pathData=\"M65.08,84.13C64.01,84.13 63.13,83.26 63.13,82.18C63.13,81.11 64,80.24 65.08,80.24C66.15,80.24 67.02,81.11 67.02,82.18C67.02,83.26 66.15,84.13 65.08,84.13ZM43.6,84.13C42.53,84.13 41.65,83.26 41.65,82.18C41.65,81.11 42.52,80.24 43.6,80.24C44.66,80.24 45.54,81.11 45.54,82.18C45.54,83.26 44.67,84.13 43.6,84.13ZM65.77,72.44L69.66,65.73C69.88,65.35 69.74,64.85 69.36,64.63C68.97,64.41 68.48,64.54 68.25,64.93L64.32,71.73C61.31,70.36 57.94,69.59 54.33,69.59C50.73,69.59 47.35,70.36 44.34,71.73L40.41,64.93C40.19,64.54 39.69,64.41 39.31,64.63C38.92,64.85 38.79,65.35 39.01,65.73L42.89,72.44C36.22,76.07 31.67,82.81 31,90.77H77.67C77,82.8 72.44,76.06 65.77,72.44Z\"\n      android:fillColor=\"#000000\"/>\n  <path\n      android:pathData=\"M46.57,35H46.57C46.1,35 45.72,35.38 45.72,35.85L45.72,43.15H44.19C43.35,43.15 42.67,43.83 42.67,44.68C42.67,45.52 43.35,46.2 44.19,46.2H45.72V43.15H47.42C48.17,43.15 48.78,42.54 48.78,41.79L48.78,37.72H49.97C50.43,37.72 50.81,37.34 50.81,36.87V35.85C50.81,35.38 50.43,35 49.97,35H47.42H46.57ZM46.57,54.35H46.57H47.42H49.97C50.43,54.35 50.81,53.97 50.81,53.5V52.48C50.81,52.02 50.43,51.64 49.97,51.64H48.78L48.78,47.56C48.78,46.81 48.17,46.2 47.42,46.2H45.72L45.72,53.5C45.72,53.97 46.1,54.35 46.57,54.35ZM61.54,35H61.54C62.01,35 62.39,35.38 62.39,35.85V43.15H63.92C64.76,43.15 65.44,43.83 65.44,44.68C65.44,45.52 64.76,46.2 63.92,46.2H62.39V43.15H60.69C59.94,43.15 59.33,42.54 59.33,41.79V37.72H58.15C57.68,37.72 57.3,37.34 57.3,36.87V35.85C57.3,35.38 57.68,35 58.15,35H60.69H61.54ZM61.54,54.35H61.54H60.69H58.15C57.68,54.35 57.3,53.97 57.3,53.5V52.48C57.3,52.02 57.68,51.64 58.15,51.64H59.33V47.56C59.33,46.81 59.94,46.2 60.69,46.2H62.39V53.5C62.39,53.97 62.01,54.35 61.54,54.35Z\"\n      android:fillColor=\"#000000\"\n      android:fillType=\"evenOdd\"/>\n</vector>\n"
  },
  {
    "path": "app-nia-catalog/src/main/res/mipmap-anydpi-v26/ic_launcher.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <background android:drawable=\"@drawable/ic_launcher_background\"/>\n    <foreground android:drawable=\"@drawable/ic_launcher_foreground\"/>\n    <monochrome android:drawable=\"@drawable/ic_launcher_foreground\" />\n</adaptive-icon>\n"
  },
  {
    "path": "app-nia-catalog/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <background android:drawable=\"@drawable/ic_launcher_background\"/>\n    <foreground android:drawable=\"@drawable/ic_launcher_foreground\"/>\n    <monochrome android:drawable=\"@drawable/ic_launcher_foreground\" />\n</adaptive-icon>\n"
  },
  {
    "path": "app-nia-catalog/src/main/res/values/strings.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<resources>\n    <string name=\"app_name\">NiA Catalog</string>\n</resources>\n"
  },
  {
    "path": "app-nia-catalog/src/main/res/values/themes.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<resources>\n    <style name=\"Theme.NiaCatalog\" parent=\"android:Theme.Material.Light.NoActionBar\" />\n</resources>\n"
  },
  {
    "path": "benchmarks/README.md",
    "content": "# `:benchmarks`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :feature\n    direction TB\n    subgraph :feature:settings\n      direction TB\n      :feature:settings:impl[impl]:::android-library\n    end\n    subgraph :feature:foryou\n      direction TB\n      :feature:foryou:api[api]:::android-library\n      :feature:foryou:impl[impl]:::android-library\n    end\n    subgraph :feature:bookmarks\n      direction TB\n      :feature:bookmarks:api[api]:::android-library\n      :feature:bookmarks:impl[impl]:::android-library\n    end\n    subgraph :feature:search\n      direction TB\n      :feature:search:api[api]:::android-library\n      :feature:search:impl[impl]:::android-library\n    end\n    subgraph :feature:interests\n      direction TB\n      :feature:interests:api[api]:::android-library\n      :feature:interests:impl[impl]:::android-library\n    end\n    subgraph :feature:topic\n      direction TB\n      :feature:topic:api[api]:::android-library\n      :feature:topic:impl[impl]:::android-library\n    end\n  end\n  subgraph :sync\n    direction TB\n    :sync:work[work]:::android-library\n  end\n  subgraph :core\n    direction TB\n    :core:analytics[analytics]:::android-library\n    :core:common[common]:::jvm-library\n    :core:data[data]:::android-library\n    :core:database[database]:::android-library\n    :core:datastore[datastore]:::android-library\n    :core:datastore-proto[datastore-proto]:::jvm-library\n    :core:designsystem[designsystem]:::android-library\n    :core:domain[domain]:::android-library\n    :core:model[model]:::jvm-library\n    :core:navigation[navigation]:::android-library\n    :core:network[network]:::android-library\n    :core:notifications[notifications]:::android-library\n    :core:ui[ui]:::android-library\n  end\n  :benchmarks[benchmarks]:::android-test\n  :app[app]:::android-application\n\n  :app -.->|baselineProfile| :benchmarks\n  :app -.-> :core:analytics\n  :app -.-> :core:common\n  :app -.-> :core:data\n  :app -.-> :core:designsystem\n  :app -.-> :core:model\n  :app -.-> :core:ui\n  :app -.-> :feature:bookmarks:api\n  :app -.-> :feature:bookmarks:impl\n  :app -.-> :feature:foryou:api\n  :app -.-> :feature:foryou:impl\n  :app -.-> :feature:interests:api\n  :app -.-> :feature:interests:impl\n  :app -.-> :feature:search:api\n  :app -.-> :feature:search:impl\n  :app -.-> :feature:settings:impl\n  :app -.-> :feature:topic:api\n  :app -.-> :feature:topic:impl\n  :app -.-> :sync:work\n  :benchmarks -.->|testedApks| :app\n  :core:data -.-> :core:analytics\n  :core:data --> :core:common\n  :core:data --> :core:database\n  :core:data --> :core:datastore\n  :core:data --> :core:network\n  :core:data -.-> :core:notifications\n  :core:database --> :core:model\n  :core:datastore -.-> :core:common\n  :core:datastore --> :core:datastore-proto\n  :core:datastore --> :core:model\n  :core:domain --> :core:data\n  :core:domain --> :core:model\n  :core:network --> :core:common\n  :core:network --> :core:model\n  :core:notifications -.-> :core:common\n  :core:notifications --> :core:model\n  :core:ui --> :core:analytics\n  :core:ui --> :core:designsystem\n  :core:ui --> :core:model\n  :feature:bookmarks:api --> :core:navigation\n  :feature:bookmarks:impl -.-> :core:data\n  :feature:bookmarks:impl -.-> :core:designsystem\n  :feature:bookmarks:impl -.-> :core:ui\n  :feature:bookmarks:impl -.-> :feature:bookmarks:api\n  :feature:bookmarks:impl -.-> :feature:topic:api\n  :feature:foryou:api --> :core:navigation\n  :feature:foryou:impl -.-> :core:designsystem\n  :feature:foryou:impl -.-> :core:domain\n  :feature:foryou:impl -.-> :core:notifications\n  :feature:foryou:impl -.-> :core:ui\n  :feature:foryou:impl -.-> :feature:foryou:api\n  :feature:foryou:impl -.-> :feature:topic:api\n  :feature:interests:api --> :core:navigation\n  :feature:interests:impl -.-> :core:designsystem\n  :feature:interests:impl -.-> :core:domain\n  :feature:interests:impl -.-> :core:ui\n  :feature:interests:impl -.-> :feature:interests:api\n  :feature:interests:impl -.-> :feature:topic:api\n  :feature:search:api -.-> :core:domain\n  :feature:search:api --> :core:navigation\n  :feature:search:impl -.-> :core:designsystem\n  :feature:search:impl -.-> :core:domain\n  :feature:search:impl -.-> :core:ui\n  :feature:search:impl -.-> :feature:interests:api\n  :feature:search:impl -.-> :feature:search:api\n  :feature:search:impl -.-> :feature:topic:api\n  :feature:settings:impl -.-> :core:data\n  :feature:settings:impl -.-> :core:designsystem\n  :feature:settings:impl -.-> :core:ui\n  :feature:topic:api -.-> :core:designsystem\n  :feature:topic:api --> :core:navigation\n  :feature:topic:api -.-> :core:ui\n  :feature:topic:impl -.-> :core:data\n  :feature:topic:impl -.-> :core:designsystem\n  :feature:topic:impl -.-> :core:ui\n  :feature:topic:impl -.-> :feature:topic:api\n  :sync:work -.-> :core:analytics\n  :sync:work -.-> :core:data\n  :sync:work -.-> :core:notifications\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "benchmarks/build.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nimport com.google.samples.apps.nowinandroid.configureFlavors\n\nplugins {\n    alias(libs.plugins.baselineprofile)\n    alias(libs.plugins.nowinandroid.android.test)\n}\n\nandroid {\n    namespace = \"com.google.samples.apps.nowinandroid.benchmarks\"\n\n    defaultConfig {\n        minSdk = 28\n        testInstrumentationRunner = \"androidx.test.runner.AndroidJUnitRunner\"\n\n        buildConfigField(\"String\", \"APP_BUILD_TYPE_SUFFIX\", \"\\\"\\\"\")\n    }\n\n    buildFeatures {\n        buildConfig = true\n    }\n\n    // Use the same flavor dimensions as the application to allow generating Baseline Profiles on prod,\n    // which is more close to what will be shipped to users (no fake data), but has ability to run the\n    // benchmarks on demo, so we benchmark on stable data. \n    configureFlavors(this) { flavor ->\n        buildConfigField(\n            \"String\",\n            \"APP_FLAVOR_SUFFIX\",\n            \"\\\"${flavor.applicationIdSuffix ?: \"\"}\\\"\"\n        )\n    }\n\n    testOptions.managedDevices.localDevices {\n        create(\"pixel6Api33\") {\n            device = \"Pixel 6\"\n            apiLevel = 33\n            systemImageSource = \"aosp\"\n        }\n    }\n\n    targetProjectPath = \":app\"\n    experimentalProperties[\"android.experimental.self-instrumenting\"] = true\n}\n\nbaselineProfile {\n    // This specifies the managed devices to use that you run the tests on.\n    managedDevices.clear()\n    managedDevices += \"pixel6Api33\"\n\n    // Don't use a connected device but rely on a GMD for consistency between local and CI builds.\n    useConnectedDevices = false\n\n}\n\ndependencies {\n    implementation(libs.androidx.benchmark.macro)\n    implementation(libs.androidx.test.core)\n    implementation(libs.androidx.test.espresso.core)\n    implementation(libs.androidx.test.ext)\n    implementation(libs.androidx.test.rules)\n    implementation(libs.androidx.test.runner)\n    implementation(libs.androidx.test.uiautomator)\n}\n"
  },
  {
    "path": "benchmarks/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<manifest />"
  },
  {
    "path": "benchmarks/src/main/kotlin/androidx/test/uiautomator/UiAutomatorHelpers.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage androidx.test.uiautomator\n\nimport androidx.test.uiautomator.HasChildrenOp.AT_LEAST\nimport androidx.test.uiautomator.HasChildrenOp.AT_MOST\nimport androidx.test.uiautomator.HasChildrenOp.EXACTLY\n\n// These helpers need to be in the androidx.test.uiautomator package,\n// because the abstract class has package local method that needs to be implemented.\n\n/**\n * Condition will be satisfied if given element has specified count of children\n */\nfun untilHasChildren(\n    childCount: Int = 1,\n    op: HasChildrenOp = AT_LEAST,\n): UiObject2Condition<Boolean> = object : UiObject2Condition<Boolean>() {\n    override fun apply(element: UiObject2): Boolean = when (op) {\n        AT_LEAST -> element.childCount >= childCount\n        EXACTLY -> element.childCount == childCount\n        AT_MOST -> element.childCount <= childCount\n    }\n}\n\nenum class HasChildrenOp {\n    AT_LEAST,\n    EXACTLY,\n    AT_MOST,\n}\n"
  },
  {
    "path": "benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/BaselineProfileMetrics.kt",
    "content": "/*\n * Copyright 2024 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid\n\nimport androidx.benchmark.macro.ExperimentalMetricApi\nimport androidx.benchmark.macro.StartupTimingMetric\nimport androidx.benchmark.macro.TraceSectionMetric\n\n/**\n * Custom Metrics to measure baseline profile effectiveness.\n */\nclass BaselineProfileMetrics {\n    companion object {\n        /**\n         * A [TraceSectionMetric] that tracks the time spent in JIT compilation.\n         *\n         * This number should go down when a baseline profile is applied properly.\n         */\n        @OptIn(ExperimentalMetricApi::class)\n        val jitCompilationMetric = TraceSectionMetric(\"JIT Compiling %\", label = \"JIT compilation\")\n\n        /**\n         * A [TraceSectionMetric] that tracks the time spent in class initialization.\n         *\n         * This number should go down when a baseline profile is applied properly.\n         */\n        @OptIn(ExperimentalMetricApi::class)\n        val classInitMetric = TraceSectionMetric(\"L%/%;\", label = \"ClassInit\")\n\n        /**\n         * Metrics relevant to startup and baseline profile effectiveness measurement.\n         */\n        @OptIn(ExperimentalMetricApi::class)\n        val allMetrics = listOf(StartupTimingMetric(), jitCompilationMetric, classInitMetric)\n    }\n}\n"
  },
  {
    "path": "benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/GeneralActions.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid\n\nimport android.Manifest.permission\nimport android.os.Build.VERSION.SDK_INT\nimport android.os.Build.VERSION_CODES.TIRAMISU\nimport androidx.benchmark.macro.MacrobenchmarkScope\nimport androidx.test.uiautomator.By\nimport androidx.test.uiautomator.BySelector\nimport androidx.test.uiautomator.UiObject2\nimport androidx.test.uiautomator.Until\n\n/**\n * Because the app under test is different from the one running the instrumentation test,\n * the permission has to be granted manually by either:\n *\n * - tapping the Allow button\n *    ```kotlin\n *    val obj = By.text(\"Allow\")\n *    val dialog = device.wait(Until.findObject(obj), TIMEOUT)\n *    dialog?.let {\n *        it.click()\n *        device.wait(Until.gone(obj), 5_000)\n *    }\n *    ```\n * - or (preferred) executing the grant command on the target package.\n */\nfun MacrobenchmarkScope.allowNotifications() {\n    if (SDK_INT >= TIRAMISU) {\n        val command = \"pm grant $packageName ${permission.POST_NOTIFICATIONS}\"\n        device.executeShellCommand(command)\n    }\n}\n\n/**\n * Wraps starting the default activity, waiting for it to start and then allowing notifications in\n * one convenient call.\n */\nfun MacrobenchmarkScope.startActivityAndAllowNotifications() {\n    startActivityAndWait()\n    allowNotifications()\n}\n\n/**\n * Waits for and returns the `niaTopAppBar`\n */\nfun MacrobenchmarkScope.getTopAppBar(): UiObject2 {\n    device.wait(Until.hasObject(By.res(\"niaTopAppBar\")), 2_000)\n    return device.findObject(By.res(\"niaTopAppBar\"))\n}\n\n/**\n * Waits for an object on the top app bar, passed in as [selector].\n */\nfun MacrobenchmarkScope.waitForObjectOnTopAppBar(selector: BySelector, timeout: Long = 2_000) {\n    getTopAppBar().wait(Until.hasObject(selector), timeout)\n}\n"
  },
  {
    "path": "benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/Utils.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid\n\nimport androidx.test.uiautomator.BySelector\nimport androidx.test.uiautomator.Direction\nimport androidx.test.uiautomator.UiDevice\nimport androidx.test.uiautomator.UiObject2\nimport androidx.test.uiautomator.Until\nimport com.google.samples.apps.nowinandroid.benchmarks.BuildConfig\nimport java.io.ByteArrayOutputStream\n\n/**\n * Convenience parameter to use proper package name with regards to build type and build flavor.\n */\nval PACKAGE_NAME = buildString {\n    append(\"com.google.samples.apps.nowinandroid\")\n    append(BuildConfig.APP_FLAVOR_SUFFIX)\n}\n\nfun UiDevice.flingElementDownUp(element: UiObject2) {\n    // Set some margin from the sides to prevent triggering system navigation\n    element.setGestureMargin(displayWidth / 5)\n\n    element.fling(Direction.DOWN)\n    waitForIdle()\n    element.fling(Direction.UP)\n}\n\n/**\n * Waits until an object with [selector] if visible on screen and returns the object.\n * If the element is not available in [timeout], throws [AssertionError]\n */\nfun UiDevice.waitAndFindObject(selector: BySelector, timeout: Long): UiObject2 {\n    if (!wait(Until.hasObject(selector), timeout)) {\n        throw AssertionError(\"Element not found on screen in ${timeout}ms (selector=$selector)\")\n    }\n\n    return findObject(selector)\n}\n\n/**\n * Helper to dump window hierarchy into a string.\n */\nfun UiDevice.dumpWindowHierarchy(): String {\n    val buffer = ByteArrayOutputStream()\n    dumpWindowHierarchy(buffer)\n    return buffer.toString()\n}\n"
  },
  {
    "path": "benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/BookmarksBaselineProfile.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.baselineprofile\n\nimport androidx.benchmark.macro.junit4.BaselineProfileRule\nimport com.google.samples.apps.nowinandroid.PACKAGE_NAME\nimport com.google.samples.apps.nowinandroid.bookmarks.goToBookmarksScreen\nimport com.google.samples.apps.nowinandroid.startActivityAndAllowNotifications\nimport org.junit.Rule\nimport org.junit.Test\n\n/**\n * Baseline Profile of the \"Bookmarks\" screen\n */\nclass BookmarksBaselineProfile {\n    @get:Rule val baselineProfileRule = BaselineProfileRule()\n\n    @Test\n    fun generate() =\n        baselineProfileRule.collect(PACKAGE_NAME) {\n            startActivityAndAllowNotifications()\n\n            // Navigate to saved screen\n            goToBookmarksScreen()\n        }\n}\n"
  },
  {
    "path": "benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/ForYouBaselineProfile.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.baselineprofile\n\nimport androidx.benchmark.macro.junit4.BaselineProfileRule\nimport com.google.samples.apps.nowinandroid.PACKAGE_NAME\nimport com.google.samples.apps.nowinandroid.foryou.forYouScrollFeedDownUp\nimport com.google.samples.apps.nowinandroid.foryou.forYouSelectTopics\nimport com.google.samples.apps.nowinandroid.foryou.forYouWaitForContent\nimport com.google.samples.apps.nowinandroid.startActivityAndAllowNotifications\nimport org.junit.Rule\nimport org.junit.Test\n\n/**\n * Baseline Profile of the \"For You\" screen\n */\nclass ForYouBaselineProfile {\n    @get:Rule val baselineProfileRule = BaselineProfileRule()\n\n    @Test\n    fun generate() =\n        baselineProfileRule.collect(PACKAGE_NAME) {\n            startActivityAndAllowNotifications()\n\n            // Scroll the feed critical user journey\n            forYouWaitForContent()\n            forYouSelectTopics(true)\n            forYouScrollFeedDownUp()\n        }\n}\n"
  },
  {
    "path": "benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/InterestsBaselineProfile.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.baselineprofile\n\nimport androidx.benchmark.macro.junit4.BaselineProfileRule\nimport com.google.samples.apps.nowinandroid.PACKAGE_NAME\nimport com.google.samples.apps.nowinandroid.interests.goToInterestsScreen\nimport com.google.samples.apps.nowinandroid.interests.interestsScrollTopicsDownUp\nimport com.google.samples.apps.nowinandroid.startActivityAndAllowNotifications\nimport org.junit.Rule\nimport org.junit.Test\n\n/**\n * Baseline Profile of the \"Interests\" screen\n */\nclass InterestsBaselineProfile {\n    @get:Rule val baselineProfileRule = BaselineProfileRule()\n\n    @Test\n    fun generate() =\n        baselineProfileRule.collect(PACKAGE_NAME) {\n            startActivityAndAllowNotifications()\n\n            // Navigate to interests screen\n            goToInterestsScreen()\n            interestsScrollTopicsDownUp()\n        }\n}\n"
  },
  {
    "path": "benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/StartupBaselineProfile.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.baselineprofile\n\nimport androidx.benchmark.macro.MacrobenchmarkScope\nimport androidx.benchmark.macro.junit4.BaselineProfileRule\nimport com.google.samples.apps.nowinandroid.PACKAGE_NAME\nimport com.google.samples.apps.nowinandroid.startActivityAndAllowNotifications\nimport org.junit.Rule\nimport org.junit.Test\n\n/**\n * Baseline Profile for app startup. This profile also enables using [Dex Layout Optimizations](https://developer.android.com/topic/performance/baselineprofiles/dex-layout-optimizations)\n * via the `includeInStartupProfile` parameter.\n */\nclass StartupBaselineProfile {\n    @get:Rule val baselineProfileRule = BaselineProfileRule()\n\n    @Test\n    fun generate() = baselineProfileRule.collect(\n        PACKAGE_NAME,\n        includeInStartupProfile = true,\n        profileBlock = MacrobenchmarkScope::startActivityAndAllowNotifications,\n    )\n}\n"
  },
  {
    "path": "benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/bookmarks/BookmarksActions.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.bookmarks\n\nimport androidx.benchmark.macro.MacrobenchmarkScope\nimport androidx.test.uiautomator.By\nimport com.google.samples.apps.nowinandroid.waitForObjectOnTopAppBar\n\nfun MacrobenchmarkScope.goToBookmarksScreen() {\n    val savedSelector = By.text(\"Saved\")\n    val savedButton = device.findObject(savedSelector)\n    savedButton.click()\n    device.waitForIdle()\n    // Wait until saved title are shown on screen\n    waitForObjectOnTopAppBar(savedSelector)\n}\n"
  },
  {
    "path": "benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/foryou/ForYouActions.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.foryou\n\nimport androidx.benchmark.macro.MacrobenchmarkScope\nimport androidx.test.uiautomator.By\nimport androidx.test.uiautomator.Until\nimport androidx.test.uiautomator.untilHasChildren\nimport com.google.samples.apps.nowinandroid.flingElementDownUp\nimport com.google.samples.apps.nowinandroid.waitAndFindObject\nimport com.google.samples.apps.nowinandroid.waitForObjectOnTopAppBar\nimport org.junit.Assert.fail\n\nfun MacrobenchmarkScope.forYouWaitForContent() {\n    // Wait until content is loaded by checking if topics are loaded\n    device.wait(Until.gone(By.res(\"loadingWheel\")), 5_000)\n    // Sometimes, the loading wheel is gone, but the content is not loaded yet\n    // So we'll wait here for topics to be sure\n    val obj = device.waitAndFindObject(By.res(\"forYou:topicSelection\"), 10_000)\n    // Timeout here is quite big, because sometimes data loading takes a long time!\n    obj.wait(untilHasChildren(), 60_000)\n}\n\n/**\n * Selects some topics, which will show the feed content for them.\n * [recheckTopicsIfChecked] Topics may be already checked from the previous iteration.\n */\nfun MacrobenchmarkScope.forYouSelectTopics(recheckTopicsIfChecked: Boolean = false) {\n    val topics = device.findObject(By.res(\"forYou:topicSelection\"))\n\n    // Set gesture margin from sides not to trigger system gesture navigation\n    val horizontalMargin = 10 * topics.visibleBounds.width() / 100\n    topics.setGestureMargins(horizontalMargin, 0, horizontalMargin, 0)\n\n    // Select some topics to show some feed content\n    var index = 0\n    var visited = 0\n\n    while (visited < 3) {\n        if (topics.childCount == 0) {\n            fail(\"No topics found, can't generate profile for ForYou page.\")\n        }\n        // Selecting some topics, which will populate items in the feed.\n        val topic = topics.children[index % topics.childCount]\n        // Find the checkable element to figure out whether it's checked or not\n        val topicCheckIcon = topic.findObject(By.checkable(true))\n        // Topic icon may not be visible if it's out of the screen boundaries\n        // If that's the case, let's try another index\n        if (topicCheckIcon == null) {\n            index++\n            continue\n        }\n\n        when {\n            // Topic wasn't checked, so just do that\n            !topicCheckIcon.isChecked -> {\n                topic.click()\n                device.waitForIdle()\n            }\n\n            // Topic was checked already and we want to recheck it, so just do it twice\n            recheckTopicsIfChecked -> {\n                repeat(2) {\n                    topic.click()\n                    device.waitForIdle()\n                }\n            }\n\n            else -> {\n                // Topic is checked, but we don't recheck it\n            }\n        }\n\n        index++\n        visited++\n    }\n}\n\nfun MacrobenchmarkScope.forYouScrollFeedDownUp() {\n    val feedList = device.findObject(By.res(\"forYou:feed\"))\n    device.flingElementDownUp(feedList)\n}\n\nfun MacrobenchmarkScope.setAppTheme(isDark: Boolean) {\n    when (isDark) {\n        true -> device.findObject(By.text(\"Dark\")).click()\n        false -> device.findObject(By.text(\"Light\")).click()\n    }\n    device.waitForIdle()\n    device.findObject(By.text(\"OK\")).click()\n\n    // Wait until the top app bar is visible on screen\n    waitForObjectOnTopAppBar(By.text(\"Now in Android\"))\n}\n"
  },
  {
    "path": "benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/foryou/ScrollForYouFeedBenchmark.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.foryou\n\nimport androidx.benchmark.macro.CompilationMode\nimport androidx.benchmark.macro.FrameTimingMetric\nimport androidx.benchmark.macro.StartupMode\nimport androidx.benchmark.macro.junit4.MacrobenchmarkRule\nimport androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner\nimport com.google.samples.apps.nowinandroid.PACKAGE_NAME\nimport com.google.samples.apps.nowinandroid.startActivityAndAllowNotifications\nimport org.junit.Rule\nimport org.junit.Test\nimport org.junit.runner.RunWith\n\n@RunWith(AndroidJUnit4ClassRunner::class)\nclass ScrollForYouFeedBenchmark {\n    @get:Rule\n    val benchmarkRule = MacrobenchmarkRule()\n\n    @Test\n    fun scrollFeedCompilationNone() = scrollFeed(CompilationMode.None())\n\n    @Test\n    fun scrollFeedCompilationBaselineProfile() = scrollFeed(CompilationMode.Partial())\n\n    @Test\n    fun scrollFeedCompilationFull() = scrollFeed(CompilationMode.Full())\n\n    private fun scrollFeed(compilationMode: CompilationMode) = benchmarkRule.measureRepeated(\n        packageName = PACKAGE_NAME,\n        metrics = listOf(FrameTimingMetric()),\n        compilationMode = compilationMode,\n        iterations = 10,\n        startupMode = StartupMode.WARM,\n        setupBlock = {\n            // Start the app\n            pressHome()\n            startActivityAndAllowNotifications()\n        },\n    ) {\n        forYouWaitForContent()\n        forYouSelectTopics()\n        forYouScrollFeedDownUp()\n    }\n}\n"
  },
  {
    "path": "benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsActions.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.interests\n\nimport androidx.benchmark.macro.MacrobenchmarkScope\nimport androidx.test.uiautomator.By\nimport androidx.test.uiautomator.Until\nimport com.google.samples.apps.nowinandroid.flingElementDownUp\nimport com.google.samples.apps.nowinandroid.waitForObjectOnTopAppBar\n\nfun MacrobenchmarkScope.goToInterestsScreen() {\n    device.findObject(By.text(\"Interests\")).click()\n    device.waitForIdle()\n    // Wait until interests are shown on screen\n    waitForObjectOnTopAppBar(By.text(\"Interests\"))\n\n    // Wait until content is loaded by checking if interests are loaded\n    device.wait(Until.gone(By.res(\"loadingWheel\")), 5_000)\n}\n\nfun MacrobenchmarkScope.interestsScrollTopicsDownUp() {\n    device.wait(Until.hasObject(By.res(\"interests:topics\")), 5_000)\n    val topicsList = device.findObject(By.res(\"interests:topics\"))\n    device.flingElementDownUp(topicsList)\n}\n\nfun MacrobenchmarkScope.interestsWaitForTopics() {\n    device.wait(Until.hasObject(By.text(\"Accessibility\")), 30_000)\n}\n\nfun MacrobenchmarkScope.interestsToggleBookmarked() {\n    val topicsList = device.findObject(By.res(\"interests:topics\"))\n    val checkable = topicsList.findObject(By.checkable(true))\n    checkable.click()\n    device.waitForIdle()\n}\n"
  },
  {
    "path": "benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/ScrollTopicListBenchmark.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.interests\n\nimport androidx.benchmark.macro.CompilationMode\nimport androidx.benchmark.macro.FrameTimingMetric\nimport androidx.benchmark.macro.StartupMode\nimport androidx.benchmark.macro.junit4.MacrobenchmarkRule\nimport androidx.test.ext.junit.runners.AndroidJUnit4\nimport androidx.test.uiautomator.By\nimport com.google.samples.apps.nowinandroid.PACKAGE_NAME\nimport com.google.samples.apps.nowinandroid.startActivityAndAllowNotifications\nimport org.junit.Rule\nimport org.junit.Test\nimport org.junit.runner.RunWith\n\n@RunWith(AndroidJUnit4::class)\nclass ScrollTopicListBenchmark {\n    @get:Rule\n    val benchmarkRule = MacrobenchmarkRule()\n\n    @Test\n    fun benchmarkStateChangeCompilationBaselineProfile() =\n        benchmarkStateChange(CompilationMode.Partial())\n\n    private fun benchmarkStateChange(compilationMode: CompilationMode) =\n        benchmarkRule.measureRepeated(\n            packageName = PACKAGE_NAME,\n            metrics = listOf(FrameTimingMetric()),\n            compilationMode = compilationMode,\n            iterations = 10,\n            startupMode = StartupMode.WARM,\n            setupBlock = {\n                // Start the app\n                pressHome()\n                startActivityAndAllowNotifications()\n                // Navigate to interests screen\n                device.findObject(By.text(\"Interests\")).click()\n                device.waitForIdle()\n            },\n        ) {\n            interestsWaitForTopics()\n            repeat(3) {\n                interestsScrollTopicsDownUp()\n            }\n        }\n}\n"
  },
  {
    "path": "benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/ScrollTopicListPowerMetricsBenchmark.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.interests\n\nimport android.os.Build.VERSION_CODES\nimport androidx.annotation.RequiresApi\nimport androidx.benchmark.macro.CompilationMode\nimport androidx.benchmark.macro.ExperimentalMetricApi\nimport androidx.benchmark.macro.FrameTimingMetric\nimport androidx.benchmark.macro.PowerCategory\nimport androidx.benchmark.macro.PowerCategoryDisplayLevel\nimport androidx.benchmark.macro.PowerMetric\nimport androidx.benchmark.macro.StartupMode\nimport androidx.benchmark.macro.junit4.MacrobenchmarkRule\nimport androidx.test.ext.junit.runners.AndroidJUnit4\nimport androidx.test.uiautomator.By\nimport com.google.samples.apps.nowinandroid.PACKAGE_NAME\nimport com.google.samples.apps.nowinandroid.allowNotifications\nimport com.google.samples.apps.nowinandroid.foryou.forYouScrollFeedDownUp\nimport com.google.samples.apps.nowinandroid.foryou.forYouSelectTopics\nimport com.google.samples.apps.nowinandroid.foryou.forYouWaitForContent\nimport com.google.samples.apps.nowinandroid.foryou.setAppTheme\nimport org.junit.Rule\nimport org.junit.Test\nimport org.junit.runner.RunWith\n\n@OptIn(ExperimentalMetricApi::class)\n@RequiresApi(VERSION_CODES.Q)\n@RunWith(AndroidJUnit4::class)\nclass ScrollTopicListPowerMetricsBenchmark {\n    @get:Rule\n    val benchmarkRule = MacrobenchmarkRule()\n\n    private val categories = PowerCategory.entries\n        .associateWith { PowerCategoryDisplayLevel.TOTAL }\n\n    @Test\n    fun benchmarkStateChangeCompilationLight() =\n        benchmarkStateChangeWithTheme(CompilationMode.Partial(), false)\n\n    @Test\n    fun benchmarkStateChangeCompilationDark() =\n        benchmarkStateChangeWithTheme(CompilationMode.Partial(), true)\n\n    private fun benchmarkStateChangeWithTheme(compilationMode: CompilationMode, isDark: Boolean) =\n        benchmarkRule.measureRepeated(\n            packageName = PACKAGE_NAME,\n            metrics = listOf(FrameTimingMetric(), PowerMetric(PowerMetric.Energy(categories))),\n            compilationMode = compilationMode,\n            iterations = 2,\n            startupMode = StartupMode.WARM,\n            setupBlock = {\n                // Start the app\n                pressHome()\n                startActivityAndWait()\n                allowNotifications()\n                // Navigate to Settings\n                device.findObject(By.desc(\"Settings\")).click()\n                device.waitForIdle()\n                setAppTheme(isDark)\n            },\n        ) {\n            forYouWaitForContent()\n            forYouSelectTopics()\n            repeat(3) {\n                forYouScrollFeedDownUp()\n            }\n        }\n}\n"
  },
  {
    "path": "benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/TopicsScreenRecompositionBenchmark.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.interests\n\nimport androidx.benchmark.macro.CompilationMode\nimport androidx.benchmark.macro.FrameTimingMetric\nimport androidx.benchmark.macro.StartupMode\nimport androidx.benchmark.macro.junit4.MacrobenchmarkRule\nimport androidx.test.ext.junit.runners.AndroidJUnit4\nimport androidx.test.uiautomator.By\nimport com.google.samples.apps.nowinandroid.PACKAGE_NAME\nimport com.google.samples.apps.nowinandroid.startActivityAndAllowNotifications\nimport org.junit.Rule\nimport org.junit.Test\nimport org.junit.runner.RunWith\n\n@RunWith(AndroidJUnit4::class)\nclass TopicsScreenRecompositionBenchmark {\n    @get:Rule\n    val benchmarkRule = MacrobenchmarkRule()\n\n    @Test\n    fun benchmarkStateChangeCompilationBaselineProfile() =\n        benchmarkStateChange(CompilationMode.Partial())\n\n    private fun benchmarkStateChange(compilationMode: CompilationMode) =\n        benchmarkRule.measureRepeated(\n            packageName = PACKAGE_NAME,\n            metrics = listOf(FrameTimingMetric()),\n            compilationMode = compilationMode,\n            iterations = 10,\n            startupMode = StartupMode.WARM,\n            setupBlock = {\n                // Start the app\n                pressHome()\n                startActivityAndAllowNotifications()\n                // Navigate to interests screen\n                device.findObject(By.text(\"Interests\")).click()\n                device.waitForIdle()\n            },\n        ) {\n            interestsWaitForTopics()\n            repeat(3) {\n                interestsToggleBookmarked()\n            }\n        }\n}\n"
  },
  {
    "path": "benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/startup/StartupBenchmark.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.startup\n\nimport androidx.benchmark.macro.BaselineProfileMode.Disable\nimport androidx.benchmark.macro.BaselineProfileMode.Require\nimport androidx.benchmark.macro.CompilationMode\nimport androidx.benchmark.macro.StartupMode.COLD\nimport androidx.benchmark.macro.junit4.MacrobenchmarkRule\nimport androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner\nimport com.google.samples.apps.nowinandroid.BaselineProfileMetrics\nimport com.google.samples.apps.nowinandroid.PACKAGE_NAME\nimport com.google.samples.apps.nowinandroid.allowNotifications\nimport com.google.samples.apps.nowinandroid.foryou.forYouWaitForContent\nimport com.google.samples.apps.nowinandroid.startActivityAndAllowNotifications\nimport org.junit.Rule\nimport org.junit.Test\nimport org.junit.runner.RunWith\n\n/**\n * Enables app startups from various states of baseline profile or [CompilationMode]s.\n * Run this benchmark from Studio to see startup measurements, and captured system traces\n * for investigating your app's performance from a cold state.\n */\n@RunWith(AndroidJUnit4ClassRunner::class)\nclass StartupBenchmark {\n    @get:Rule\n    val benchmarkRule = MacrobenchmarkRule()\n\n    @Test\n    fun startupWithoutPreCompilation() = startup(CompilationMode.None())\n\n    @Test\n    fun startupWithPartialCompilationAndDisabledBaselineProfile() = startup(\n        CompilationMode.Partial(baselineProfileMode = Disable, warmupIterations = 1),\n    )\n\n    @Test\n    fun startupPrecompiledWithBaselineProfile() =\n        startup(CompilationMode.Partial(baselineProfileMode = Require))\n\n    @Test\n    fun startupFullyPrecompiled() = startup(CompilationMode.Full())\n\n    private fun startup(compilationMode: CompilationMode) = benchmarkRule.measureRepeated(\n        packageName = PACKAGE_NAME,\n        metrics = BaselineProfileMetrics.allMetrics,\n        compilationMode = compilationMode,\n        // More iterations result in higher statistical significance.\n        iterations = 20,\n        startupMode = COLD,\n        setupBlock = {\n            pressHome()\n            allowNotifications()\n        },\n    ) {\n        startActivityAndAllowNotifications()\n        // Waits until the content is ready to capture Time To Full Display\n        forYouWaitForContent()\n    }\n}\n"
  },
  {
    "path": "build-logic/README.md",
    "content": "# Convention Plugins\n\nThe `build-logic` folder defines project-specific convention plugins, used to keep a single\nsource of truth for common module configurations.\n\nThis approach is heavily based on\n[https://developer.squareup.com/blog/herding-elephants/](https://developer.squareup.com/blog/herding-elephants/)\nand\n[https://github.com/jjohannes/idiomatic-gradle](https://github.com/jjohannes/idiomatic-gradle).\n\nBy setting up convention plugins in `build-logic`, we can avoid duplicated build script setup,\nmessy `subproject` configurations, without the pitfalls of the `buildSrc` directory.\n\n`build-logic` is an included build, as configured in the root\n[`settings.gradle.kts`](../settings.gradle.kts).\n\nInside `build-logic` is a `convention` module, which defines a set of plugins that all normal\nmodules can use to configure themselves.\n\n`build-logic` also includes a set of `Kotlin` files used to share logic between plugins themselves,\nwhich is most useful for configuring Android components (libraries vs applications) with shared\ncode.\n\nThese plugins are *additive* and *composable*, and try to only accomplish a single responsibility.\nModules can then pick and choose the configurations they need.\nIf there is one-off logic for a module without shared code, it's preferable to define that directly\nin the module's `build.gradle`, as opposed to creating a convention plugin with module-specific\nsetup.\n\nCurrent list of convention plugins:\n\n- [`nowinandroid.android.application`](convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt),\n  [`nowinandroid.android.library`](convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt),\n  [`nowinandroid.android.test`](convention/src/main/kotlin/AndroidTestConventionPlugin.kt):\n  Configures common Android and Kotlin options.\n- [`nowinandroid.android.application.compose`](convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt),\n  [`nowinandroid.android.library.compose`](convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt):\n  Configures Jetpack Compose options\n"
  },
  {
    "path": "build-logic/convention/build.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport org.jetbrains.kotlin.gradle.dsl.JvmTarget\n\nplugins {\n    `kotlin-dsl`\n    alias(libs.plugins.android.lint)\n}\n\ngroup = \"com.google.samples.apps.nowinandroid.buildlogic\"\n\n// Configure the build-logic plugins to target JDK 17\n// This matches the JDK used to build the project, and is not related to what is running on device.\njava {\n    sourceCompatibility = JavaVersion.VERSION_17\n    targetCompatibility = JavaVersion.VERSION_17\n}\n\nkotlin {\n    compilerOptions {\n        jvmTarget = JvmTarget.JVM_17\n    }\n}\n\ndependencies {\n    compileOnly(libs.android.gradlePlugin)\n    compileOnly(libs.android.tools.common)\n    compileOnly(libs.compose.gradlePlugin)\n    compileOnly(libs.firebase.crashlytics.gradlePlugin)\n    compileOnly(libs.firebase.performance.gradlePlugin)\n    compileOnly(libs.kotlin.gradlePlugin)\n    compileOnly(libs.ksp.gradlePlugin)\n    compileOnly(libs.room.gradlePlugin)\n    compileOnly(libs.spotless.gradlePlugin)\n    implementation(libs.truth)\n    lintChecks(libs.androidx.lint.gradle)\n}\n\ntasks {\n    validatePlugins {\n        enableStricterValidation = true\n        failOnWarning = true\n    }\n}\n\ngradlePlugin {\n    plugins {\n        register(\"androidApplicationCompose\") {\n            id = libs.plugins.nowinandroid.android.application.compose.get().pluginId\n            implementationClass = \"AndroidApplicationComposeConventionPlugin\"\n        }\n        register(\"androidApplication\") {\n            id = libs.plugins.nowinandroid.android.application.asProvider().get().pluginId\n            implementationClass = \"AndroidApplicationConventionPlugin\"\n        }\n        register(\"androidApplicationJacoco\") {\n            id = libs.plugins.nowinandroid.android.application.jacoco.get().pluginId\n            implementationClass = \"AndroidApplicationJacocoConventionPlugin\"\n        }\n        register(\"androidLibraryCompose\") {\n            id = libs.plugins.nowinandroid.android.library.compose.get().pluginId\n            implementationClass = \"AndroidLibraryComposeConventionPlugin\"\n        }\n        register(\"androidLibrary\") {\n            id = libs.plugins.nowinandroid.android.library.asProvider().get().pluginId\n            implementationClass = \"AndroidLibraryConventionPlugin\"\n        }\n        register(\"androidFeatureImpl\") {\n            id = libs.plugins.nowinandroid.android.feature.impl.get().pluginId\n            implementationClass = \"AndroidFeatureImplConventionPlugin\"\n        }\n        register(\"androidFeatureApi\") {\n            id = libs.plugins.nowinandroid.android.feature.api.get().pluginId\n            implementationClass = \"AndroidFeatureApiConventionPlugin\"\n        }\n        register(\"androidLibraryJacoco\") {\n            id = libs.plugins.nowinandroid.android.library.jacoco.get().pluginId\n            implementationClass = \"AndroidLibraryJacocoConventionPlugin\"\n        }\n        register(\"androidTest\") {\n            id = libs.plugins.nowinandroid.android.test.get().pluginId\n            implementationClass = \"AndroidTestConventionPlugin\"\n        }\n        register(\"hilt\") {\n            id = libs.plugins.nowinandroid.hilt.get().pluginId\n            implementationClass = \"HiltConventionPlugin\"\n        }\n        register(\"androidRoom\") {\n            id = libs.plugins.nowinandroid.android.room.get().pluginId\n            implementationClass = \"AndroidRoomConventionPlugin\"\n        }\n        register(\"androidFirebase\") {\n            id = libs.plugins.nowinandroid.android.application.firebase.get().pluginId\n            implementationClass = \"AndroidApplicationFirebaseConventionPlugin\"\n        }\n        register(\"androidFlavors\") {\n            id = libs.plugins.nowinandroid.android.application.flavors.get().pluginId\n            implementationClass = \"AndroidApplicationFlavorsConventionPlugin\"\n        }\n        register(\"androidLint\") {\n            id = libs.plugins.nowinandroid.android.lint.get().pluginId\n            implementationClass = \"AndroidLintConventionPlugin\"\n        }\n        register(\"jvmLibrary\") {\n            id = libs.plugins.nowinandroid.jvm.library.get().pluginId\n            implementationClass = \"JvmLibraryConventionPlugin\"\n        }\n        register(\"root\") {\n            id = libs.plugins.nowinandroid.root.get().pluginId\n            implementationClass = \"RootPlugin\"\n        }\n    }\n}\n"
  },
  {
    "path": "build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport com.android.build.api.dsl.ApplicationExtension\nimport com.google.samples.apps.nowinandroid.configureAndroidCompose\nimport org.gradle.api.Plugin\nimport org.gradle.api.Project\nimport org.gradle.kotlin.dsl.apply\nimport org.gradle.kotlin.dsl.getByType\n\nclass AndroidApplicationComposeConventionPlugin : Plugin<Project> {\n    override fun apply(target: Project) {\n        with(target) {\n            apply(plugin = \"com.android.application\")\n            apply(plugin = \"org.jetbrains.kotlin.plugin.compose\")\n\n            val extension = extensions.getByType<ApplicationExtension>()\n            configureAndroidCompose(extension)\n        }\n    }\n}\n"
  },
  {
    "path": "build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport com.android.build.api.dsl.ApplicationExtension\nimport com.android.build.api.variant.ApplicationAndroidComponentsExtension\nimport com.google.samples.apps.nowinandroid.configureBadgingTasks\nimport com.google.samples.apps.nowinandroid.configureGradleManagedDevices\nimport com.google.samples.apps.nowinandroid.configureKotlinAndroid\nimport com.google.samples.apps.nowinandroid.configurePrintApksTask\nimport com.google.samples.apps.nowinandroid.configureSpotlessForAndroid\nimport org.gradle.api.Plugin\nimport org.gradle.api.Project\nimport org.gradle.kotlin.dsl.apply\nimport org.gradle.kotlin.dsl.configure\n\nabstract class AndroidApplicationConventionPlugin : Plugin<Project> {\n    override fun apply(target: Project) {\n        with(target) {\n            apply(plugin = \"com.android.application\")\n            apply(plugin = \"nowinandroid.android.lint\")\n            apply(plugin = \"com.dropbox.dependency-guard\")\n\n            extensions.configure<ApplicationExtension> {\n                configureKotlinAndroid(this)\n                defaultConfig.targetSdk = 36\n                testOptions.animationsDisabled = true\n                configureGradleManagedDevices(this)\n            }\n            extensions.configure<ApplicationAndroidComponentsExtension> {\n                configurePrintApksTask(this)\n                configureBadgingTasks(this)\n            }\n            configureSpotlessForAndroid()\n        }\n    }\n}\n"
  },
  {
    "path": "build-logic/convention/src/main/kotlin/AndroidApplicationFirebaseConventionPlugin.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport com.android.build.api.dsl.ApplicationExtension\nimport com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension\nimport com.google.samples.apps.nowinandroid.libs\nimport org.gradle.api.Plugin\nimport org.gradle.api.Project\nimport org.gradle.kotlin.dsl.apply\nimport org.gradle.kotlin.dsl.configure\nimport org.gradle.kotlin.dsl.dependencies\nimport org.gradle.kotlin.dsl.exclude\n\nclass AndroidApplicationFirebaseConventionPlugin : Plugin<Project> {\n    override fun apply(target: Project) {\n        with(target) {\n            apply(plugin = \"com.google.gms.google-services\")\n            apply(plugin = \"com.google.firebase.firebase-perf\")\n            apply(plugin = \"com.google.firebase.crashlytics\")\n\n            dependencies {\n                val bom = libs.findLibrary(\"firebase-bom\").get()\n                \"implementation\"(platform(bom))\n                \"implementation\"(libs.findLibrary(\"firebase.analytics\").get())\n                \"implementation\"(libs.findLibrary(\"firebase.performance\").get()) {\n                    /*\n                    Exclusion of protobuf / protolite dependencies is necessary as the\n                    datastore-proto brings in protobuf dependencies. These are the source of truth\n                    for Now in Android.\n                    That's why the duplicate classes from below dependencies are excluded.\n                     */\n                    exclude(group = \"com.google.protobuf\", module = \"protobuf-javalite\")\n                    exclude(group = \"com.google.firebase\", module = \"protolite-well-known-types\")\n                }\n                \"implementation\"(libs.findLibrary(\"firebase.crashlytics\").get())\n            }\n\n            extensions.configure<ApplicationExtension> {\n                buildTypes.configureEach {\n                    // Disable the Crashlytics mapping file upload. This feature should only be\n                    // enabled if a Firebase backend is available and configured in\n                    // google-services.json.\n                    configure<CrashlyticsExtension> {\n                        mappingFileUploadEnabled = false\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "build-logic/convention/src/main/kotlin/AndroidApplicationFlavorsConventionPlugin.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport com.android.build.api.dsl.ApplicationExtension\nimport com.google.samples.apps.nowinandroid.configureFlavors\nimport org.gradle.api.Plugin\nimport org.gradle.api.Project\nimport org.gradle.kotlin.dsl.configure\n\nclass AndroidApplicationFlavorsConventionPlugin : Plugin<Project> {\n    override fun apply(target: Project) {\n        with(target) {\n            extensions.configure<ApplicationExtension> {\n                configureFlavors(this)\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "build-logic/convention/src/main/kotlin/AndroidApplicationJacocoConventionPlugin.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport com.android.build.api.dsl.ApplicationExtension\nimport com.android.build.api.variant.ApplicationAndroidComponentsExtension\nimport com.google.samples.apps.nowinandroid.configureJacoco\nimport org.gradle.api.Plugin\nimport org.gradle.api.Project\nimport org.gradle.kotlin.dsl.apply\nimport org.gradle.kotlin.dsl.getByType\nimport org.gradle.testing.jacoco.plugins.JacocoPlugin\n\nclass AndroidApplicationJacocoConventionPlugin : Plugin<Project> {\n    override fun apply(target: Project) {\n        with(target) {\n            apply<JacocoPlugin>()\n            configureJacoco(\n                commonExtension = extensions.getByType<ApplicationExtension>(),\n                androidComponentsExtension = extensions.getByType<ApplicationAndroidComponentsExtension>(),\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "build-logic/convention/src/main/kotlin/AndroidFeatureApiConventionPlugin.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport org.gradle.api.Plugin\nimport org.gradle.api.Project\nimport org.gradle.kotlin.dsl.apply\nimport org.gradle.kotlin.dsl.dependencies\n\nclass AndroidFeatureApiConventionPlugin : Plugin<Project> {\n    override fun apply(target: Project) {\n        with(target) {\n            apply(plugin = \"nowinandroid.android.library\")\n            apply(plugin = \"org.jetbrains.kotlin.plugin.serialization\")\n\n            dependencies {\n                \"api\"(project(\":core:navigation\"))\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "build-logic/convention/src/main/kotlin/AndroidFeatureImplConventionPlugin.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport com.android.build.api.dsl.LibraryExtension\nimport com.google.samples.apps.nowinandroid.configureGradleManagedDevices\nimport com.google.samples.apps.nowinandroid.libs\nimport org.gradle.api.Plugin\nimport org.gradle.api.Project\nimport org.gradle.kotlin.dsl.apply\nimport org.gradle.kotlin.dsl.configure\nimport org.gradle.kotlin.dsl.dependencies\n\nclass AndroidFeatureImplConventionPlugin : Plugin<Project> {\n    override fun apply(target: Project) {\n        with(target) {\n            apply(plugin = \"nowinandroid.android.library\")\n            apply(plugin = \"nowinandroid.hilt\")\n\n            extensions.configure<LibraryExtension> {\n                testOptions.animationsDisabled = true\n                configureGradleManagedDevices(this)\n            }\n\n            dependencies {\n                \"implementation\"(project(\":core:ui\"))\n                \"implementation\"(project(\":core:designsystem\"))\n\n                \"implementation\"(libs.findLibrary(\"androidx.lifecycle.runtimeCompose\").get())\n                \"implementation\"(libs.findLibrary(\"androidx.lifecycle.viewModelCompose\").get())\n                \"implementation\"(libs.findLibrary(\"androidx.hilt.lifecycle.viewModelCompose\").get())\n                \"implementation\"(libs.findLibrary(\"androidx.navigation3.runtime\").get())\n                \"implementation\"(libs.findLibrary(\"androidx.tracing.ktx\").get())\n\n                \"androidTestImplementation\"(\n                    libs.findLibrary(\"androidx.lifecycle.runtimeTesting\").get(),\n                )\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport com.android.build.api.dsl.LibraryExtension\nimport com.google.samples.apps.nowinandroid.configureAndroidCompose\nimport org.gradle.api.Plugin\nimport org.gradle.api.Project\nimport org.gradle.kotlin.dsl.apply\nimport org.gradle.kotlin.dsl.getByType\n\nclass AndroidLibraryComposeConventionPlugin : Plugin<Project> {\n    override fun apply(target: Project) {\n        with(target) {\n            apply(plugin = \"com.android.library\")\n            apply(plugin = \"org.jetbrains.kotlin.plugin.compose\")\n\n            val extension = extensions.getByType<LibraryExtension>()\n            configureAndroidCompose(extension)\n        }\n    }\n}\n"
  },
  {
    "path": "build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport com.android.build.api.dsl.LibraryExtension\nimport com.android.build.api.variant.LibraryAndroidComponentsExtension\nimport com.google.samples.apps.nowinandroid.configureFlavors\nimport com.google.samples.apps.nowinandroid.configureGradleManagedDevices\nimport com.google.samples.apps.nowinandroid.configureKotlinAndroid\nimport com.google.samples.apps.nowinandroid.configurePrintApksTask\nimport com.google.samples.apps.nowinandroid.configureSpotlessForAndroid\nimport com.google.samples.apps.nowinandroid.disableUnnecessaryAndroidTests\nimport com.google.samples.apps.nowinandroid.libs\nimport org.gradle.api.Plugin\nimport org.gradle.api.Project\nimport org.gradle.kotlin.dsl.apply\nimport org.gradle.kotlin.dsl.configure\nimport org.gradle.kotlin.dsl.dependencies\n\nabstract class AndroidLibraryConventionPlugin : Plugin<Project> {\n    override fun apply(target: Project) {\n        with(target) {\n            apply(plugin = \"com.android.library\")\n            apply(plugin = \"nowinandroid.android.lint\")\n\n            extensions.configure<LibraryExtension> {\n                configureKotlinAndroid(this)\n                testOptions.targetSdk = 36\n                lint.targetSdk = 36\n                defaultConfig.testInstrumentationRunner = \"androidx.test.runner.AndroidJUnitRunner\"\n                testOptions.animationsDisabled = true\n                configureFlavors(this)\n                configureGradleManagedDevices(this)\n                // The resource prefix is derived from the module name,\n                // so resources inside \":core:module1\" must be prefixed with \"core_module1_\"\n                resourcePrefix =\n                    path.split(\"\"\"\\W\"\"\".toRegex()).drop(1).distinct().joinToString(separator = \"_\")\n                        .lowercase() + \"_\"\n            }\n            extensions.configure<LibraryAndroidComponentsExtension> {\n                configurePrintApksTask(this)\n                disableUnnecessaryAndroidTests(target)\n            }\n            configureSpotlessForAndroid()\n            dependencies {\n                \"androidTestImplementation\"(libs.findLibrary(\"kotlin.test\").get())\n                \"testImplementation\"(libs.findLibrary(\"kotlin.test\").get())\n                \"testImplementation\"(libs.findLibrary(\"junit\").get())\n\n                \"implementation\"(libs.findLibrary(\"androidx.tracing.ktx\").get())\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "build-logic/convention/src/main/kotlin/AndroidLibraryJacocoConventionPlugin.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport com.android.build.api.dsl.LibraryExtension\nimport com.android.build.api.variant.LibraryAndroidComponentsExtension\nimport com.google.samples.apps.nowinandroid.configureJacoco\nimport org.gradle.api.Plugin\nimport org.gradle.api.Project\nimport org.gradle.kotlin.dsl.apply\nimport org.gradle.kotlin.dsl.getByType\nimport org.gradle.testing.jacoco.plugins.JacocoPlugin\n\nclass AndroidLibraryJacocoConventionPlugin : Plugin<Project> {\n    override fun apply(target: Project) {\n        with(target) {\n            apply<JacocoPlugin>()\n            configureJacoco(\n                commonExtension = extensions.getByType<LibraryExtension>(),\n                androidComponentsExtension = extensions.getByType<LibraryAndroidComponentsExtension>(),\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "build-logic/convention/src/main/kotlin/AndroidLintConventionPlugin.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport com.android.build.api.dsl.ApplicationExtension\nimport com.android.build.api.dsl.LibraryExtension\nimport com.android.build.api.dsl.Lint\nimport org.gradle.api.Plugin\nimport org.gradle.api.Project\nimport org.gradle.kotlin.dsl.apply\nimport org.gradle.kotlin.dsl.configure\n\nclass AndroidLintConventionPlugin : Plugin<Project> {\n    override fun apply(target: Project) {\n        with(target) {\n            when {\n                pluginManager.hasPlugin(\"com.android.application\") ->\n                    configure<ApplicationExtension> { lint(Lint::configure) }\n\n                pluginManager.hasPlugin(\"com.android.library\") ->\n                    configure<LibraryExtension> { lint(Lint::configure) }\n\n                else -> {\n                    apply(plugin = \"com.android.lint\")\n                    configure<Lint>(Lint::configure)\n                }\n            }\n        }\n    }\n}\n\nprivate fun Lint.configure() {\n    xmlReport = true\n    sarifReport = true\n    checkDependencies = true\n    disable += \"GradleDependency\"\n}\n"
  },
  {
    "path": "build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport androidx.room.gradle.RoomExtension\nimport com.google.devtools.ksp.gradle.KspExtension\nimport com.google.samples.apps.nowinandroid.libs\nimport org.gradle.api.Plugin\nimport org.gradle.api.Project\nimport org.gradle.kotlin.dsl.apply\nimport org.gradle.kotlin.dsl.configure\nimport org.gradle.kotlin.dsl.dependencies\n\nclass AndroidRoomConventionPlugin : Plugin<Project> {\n\n    override fun apply(target: Project) {\n        with(target) {\n            apply(plugin = \"androidx.room\")\n            apply(plugin = \"com.google.devtools.ksp\")\n\n            extensions.configure<KspExtension> {\n                arg(\"room.generateKotlin\", \"true\")\n            }\n\n            extensions.configure<RoomExtension> {\n                // The schemas directory contains a schema file for each version of the Room database.\n                // This is required to enable Room auto migrations.\n                // See https://developer.android.com/reference/kotlin/androidx/room/AutoMigration.\n                schemaDirectory(\"$projectDir/schemas\")\n            }\n\n            dependencies {\n                \"implementation\"(libs.findLibrary(\"room.runtime\").get())\n                \"implementation\"(libs.findLibrary(\"room.ktx\").get())\n                \"ksp\"(libs.findLibrary(\"room.compiler\").get())\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "build-logic/convention/src/main/kotlin/AndroidTestConventionPlugin.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport com.android.build.api.dsl.TestExtension\nimport com.google.samples.apps.nowinandroid.configureGradleManagedDevices\nimport com.google.samples.apps.nowinandroid.configureKotlinAndroid\nimport org.gradle.api.Plugin\nimport org.gradle.api.Project\nimport org.gradle.kotlin.dsl.apply\nimport org.gradle.kotlin.dsl.configure\n\nclass AndroidTestConventionPlugin : Plugin<Project> {\n    override fun apply(target: Project) {\n        with(target) {\n            apply(plugin = \"com.android.test\")\n\n            extensions.configure<TestExtension> {\n                configureKotlinAndroid(this)\n                defaultConfig.targetSdk = 36\n                configureGradleManagedDevices(this)\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "build-logic/convention/src/main/kotlin/HiltConventionPlugin.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport com.android.build.gradle.api.AndroidBasePlugin\nimport com.google.samples.apps.nowinandroid.libs\nimport org.gradle.api.Plugin\nimport org.gradle.api.Project\nimport org.gradle.kotlin.dsl.apply\nimport org.gradle.kotlin.dsl.dependencies\n\nclass HiltConventionPlugin : Plugin<Project> {\n    override fun apply(target: Project) {\n        with(target) {\n            apply(plugin = \"com.google.devtools.ksp\")\n\n            dependencies {\n                \"ksp\"(libs.findLibrary(\"hilt.compiler\").get())\n                \"ksp\"(libs.findLibrary(\"kotlin.metadata\").get())\n            }\n\n            // Add support for Jvm Module, base on org.jetbrains.kotlin.jvm\n            pluginManager.withPlugin(\"org.jetbrains.kotlin.jvm\") {\n                dependencies {\n                    \"implementation\"(libs.findLibrary(\"hilt.core\").get())\n                }\n            }\n\n            /** Add support for Android modules, based on [AndroidBasePlugin] */\n            pluginManager.withPlugin(\"com.android.base\") {\n                apply(plugin = \"dagger.hilt.android.plugin\")\n                dependencies {\n                    \"implementation\"(libs.findLibrary(\"hilt.android\").get())\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "build-logic/convention/src/main/kotlin/JvmLibraryConventionPlugin.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport com.google.samples.apps.nowinandroid.configureKotlinJvm\nimport com.google.samples.apps.nowinandroid.configureSpotlessForJvm\nimport com.google.samples.apps.nowinandroid.libs\nimport org.gradle.api.Plugin\nimport org.gradle.api.Project\nimport org.gradle.kotlin.dsl.apply\nimport org.gradle.kotlin.dsl.dependencies\n\nabstract class JvmLibraryConventionPlugin : Plugin<Project> {\n    override fun apply(target: Project) {\n        with(target) {\n            apply(plugin = \"org.jetbrains.kotlin.jvm\")\n            apply(plugin = \"nowinandroid.android.lint\")\n\n            configureKotlinJvm()\n            configureSpotlessForJvm()\n            dependencies {\n                \"testImplementation\"(libs.findLibrary(\"kotlin.test\").get())\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "build-logic/convention/src/main/kotlin/RootPlugin.kt",
    "content": "/*\n * Copyright 2025 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport com.google.samples.apps.nowinandroid.configureGraphTasks\nimport com.google.samples.apps.nowinandroid.configureSpotlessForRootProject\nimport org.gradle.api.Plugin\nimport org.gradle.api.Project\nimport org.gradle.api.configuration.BuildFeatures\nimport javax.inject.Inject\n\nabstract class RootPlugin : Plugin<Project> {\n    @get:Inject abstract val buildFeatures: BuildFeatures\n\n    override fun apply(target: Project) {\n        require(target.path == \":\")\n        if (!buildFeatures.isIsolatedProjectsEnabled()) {\n            target.subprojects { configureGraphTasks() }\n        }\n        target.configureSpotlessForRootProject()\n    }\n}\n\nprivate fun BuildFeatures.isIsolatedProjectsEnabled(): Boolean {\n    return isolatedProjects.active.getOrElse(false)\n}\n"
  },
  {
    "path": "build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid\n\nimport com.android.build.api.dsl.CommonExtension\nimport org.gradle.api.Project\nimport org.gradle.api.provider.Provider\nimport org.gradle.kotlin.dsl.configure\nimport org.gradle.kotlin.dsl.dependencies\nimport org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension\n\n/**\n * Configure Compose-specific options\n */\ninternal fun Project.configureAndroidCompose(\n    commonExtension: CommonExtension,\n) {\n    commonExtension.apply {\n        buildFeatures.apply {\n            compose = true\n        }\n\n        dependencies {\n            val bom = libs.findLibrary(\"androidx-compose-bom\").get()\n            \"implementation\"(platform(bom))\n            \"androidTestImplementation\"(platform(bom))\n            \"implementation\"(libs.findLibrary(\"androidx-compose-ui-tooling-preview\").get())\n            \"debugImplementation\"(libs.findLibrary(\"androidx-compose-ui-tooling\").get())\n        }\n    }\n\n    extensions.configure<ComposeCompilerGradlePluginExtension> {\n        fun Provider<String>.onlyIfTrue() = flatMap { provider { it.takeIf(String::toBoolean) } }\n        fun Provider<*>.relativeToRootProject(dir: String) = map {\n            @Suppress(\"UnstableApiUsage\")\n            isolated.rootProject.projectDirectory\n                .dir(\"build\")\n                .dir(projectDir.toRelativeString(rootDir))\n        }.map { it.dir(dir) }\n\n        project.providers.gradleProperty(\"enableComposeCompilerMetrics\").onlyIfTrue()\n            .relativeToRootProject(\"compose-metrics\")\n            .let(metricsDestination::set)\n\n        project.providers.gradleProperty(\"enableComposeCompilerReports\").onlyIfTrue()\n            .relativeToRootProject(\"compose-reports\")\n            .let(reportsDestination::set)\n\n        @Suppress(\"UnstableApiUsage\")\n        stabilityConfigurationFiles\n            .add(isolated.rootProject.projectDirectory.file(\"compose_compiler_config.conf\"))\n    }\n}\n"
  },
  {
    "path": "build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidInstrumentedTests.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid\n\nimport com.android.build.api.variant.LibraryAndroidComponentsExtension\nimport org.gradle.api.Project\n\n/**\n * Disable unnecessary Android instrumented tests for the [project] if there is no `androidTest` folder.\n * Otherwise, these projects would be compiled, packaged, installed and ran only to end-up with the following message:\n *\n * > Starting 0 tests on AVD\n *\n * Note: this could be improved by checking other potential sourceSets based on buildTypes and flavors.\n */\ninternal fun LibraryAndroidComponentsExtension.disableUnnecessaryAndroidTests(\n    project: Project,\n) = beforeVariants {\n    it.androidTest.enable = it.androidTest.enable &&\n        project.projectDir.resolve(\"src/androidTest\").exists()\n}\n"
  },
  {
    "path": "build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Badging.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid\n\nimport com.android.build.api.artifact.SingleArtifact\nimport com.android.build.api.variant.Aapt2\nimport com.android.build.api.variant.ApplicationAndroidComponentsExtension\nimport com.google.common.truth.Truth.assertWithMessage\nimport org.gradle.api.DefaultTask\nimport org.gradle.api.Project\nimport org.gradle.api.file.DirectoryProperty\nimport org.gradle.api.file.RegularFileProperty\nimport org.gradle.api.provider.Property\nimport org.gradle.api.tasks.CacheableTask\nimport org.gradle.api.tasks.Copy\nimport org.gradle.api.tasks.Input\nimport org.gradle.api.tasks.InputFile\nimport org.gradle.api.tasks.OutputDirectory\nimport org.gradle.api.tasks.OutputFile\nimport org.gradle.api.tasks.PathSensitive\nimport org.gradle.api.tasks.PathSensitivity\nimport org.gradle.api.tasks.TaskAction\nimport org.gradle.kotlin.dsl.assign\nimport org.gradle.kotlin.dsl.register\nimport org.gradle.language.base.plugins.LifecycleBasePlugin\nimport org.gradle.process.ExecOperations\nimport javax.inject.Inject\n\n@CacheableTask\nabstract class GenerateBadgingTask : DefaultTask() {\n\n    @get:OutputFile\n    abstract val badging: RegularFileProperty\n\n    @get:PathSensitive(PathSensitivity.NONE)\n    @get:InputFile\n    abstract val apk: RegularFileProperty\n\n    @get:PathSensitive(PathSensitivity.NONE)\n    @get:InputFile\n    abstract val aapt2Executable: RegularFileProperty\n\n    @get:Inject\n    abstract val execOperations: ExecOperations\n\n    @TaskAction\n    fun taskAction() {\n        execOperations.exec {\n            commandLine(\n                aapt2Executable.get().asFile.absolutePath,\n                \"dump\",\n                \"badging\",\n                apk.get().asFile.absolutePath,\n            )\n            standardOutput = badging.asFile.get().outputStream()\n        }\n    }\n}\n\n@CacheableTask\nabstract class CheckBadgingTask : DefaultTask() {\n\n    // In order for the task to be up-to-date when the inputs have not changed,\n    // the task must declare an output, even if it's not used. Tasks with no\n    // output are always run regardless of whether the inputs changed\n    @get:OutputDirectory\n    abstract val output: DirectoryProperty\n\n    @get:PathSensitive(PathSensitivity.NONE)\n    @get:InputFile\n    abstract val goldenBadging: RegularFileProperty\n\n    @get:PathSensitive(PathSensitivity.NONE)\n    @get:InputFile\n    abstract val generatedBadging: RegularFileProperty\n\n    @get:Input\n    abstract val updateBadgingTaskName: Property<String>\n\n    override fun getGroup(): String = LifecycleBasePlugin.VERIFICATION_GROUP\n\n    @TaskAction\n    fun taskAction() {\n        assertWithMessage(\n            \"Generated badging is different from golden badging! \" +\n                \"If this change is intended, run ./gradlew ${updateBadgingTaskName.get()}\",\n        )\n            .that(generatedBadging.get().asFile.readText())\n            .isEqualTo(goldenBadging.get().asFile.readText())\n    }\n}\n\nprivate fun String.capitalized() = replaceFirstChar {\n    if (it.isLowerCase()) it.titlecase() else it.toString()\n}\n\nfun Project.configureBadgingTasks(\n    componentsExtension: ApplicationAndroidComponentsExtension,\n) {\n    // Registers a callback to be called, when a new variant is configured\n    componentsExtension.onVariants { variant ->\n        // Registers a new task to verify the app bundle.\n        val capitalizedVariantName = variant.name.capitalized()\n        val generateBadgingTaskName = \"generate${capitalizedVariantName}Badging\"\n        val generateBadging =\n            tasks.register<GenerateBadgingTask>(generateBadgingTaskName) {\n                apk = variant.artifacts.get(SingleArtifact.APK_FROM_BUNDLE)\n                aapt2Executable = componentsExtension.sdkComponents.aapt2.flatMap(Aapt2::executable)\n                badging = project.layout.buildDirectory.file(\n                    \"outputs/apk_from_bundle/${variant.name}/${variant.name}-badging.txt\",\n                )\n            }\n\n        val updateBadgingTaskName = \"update${capitalizedVariantName}Badging\"\n        tasks.register<Copy>(updateBadgingTaskName) {\n            from(generateBadging.map(GenerateBadgingTask::badging))\n            into(project.layout.projectDirectory)\n        }\n\n        val checkBadgingTaskName = \"check${capitalizedVariantName}Badging\"\n        tasks.register<CheckBadgingTask>(checkBadgingTaskName) {\n            goldenBadging = project.layout.projectDirectory.file(\"${variant.name}-badging.txt\")\n\n            generatedBadging.set(generateBadging.flatMap(GenerateBadgingTask::badging))\n\n            this.updateBadgingTaskName = updateBadgingTaskName\n\n            output = project.layout.buildDirectory.dir(\"intermediates/$checkBadgingTaskName\")\n        }\n    }\n}\n"
  },
  {
    "path": "build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/GradleManagedDevices.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid\n\nimport com.android.build.api.dsl.CommonExtension\nimport com.android.build.api.dsl.ManagedVirtualDevice\nimport org.gradle.kotlin.dsl.get\nimport org.gradle.kotlin.dsl.invoke\n\n/**\n * Configure project for Gradle managed devices\n */\ninternal fun configureGradleManagedDevices(\n    commonExtension: CommonExtension,\n) {\n    val pixel4 = DeviceConfig(\"Pixel 4\", 30, \"aosp-atd\")\n    val pixel6 = DeviceConfig(\"Pixel 6\", 31, \"aosp\")\n    val pixelC = DeviceConfig(\"Pixel C\", 30, \"aosp-atd\")\n\n    val allDevices = listOf(pixel4, pixel6, pixelC)\n    val ciDevices = listOf(pixel4, pixelC)\n\n    commonExtension.testOptions.apply {\n        @Suppress(\"UnstableApiUsage\")\n        managedDevices {\n            allDevices {\n                allDevices.forEach { deviceConfig ->\n                    maybeCreate(deviceConfig.taskName, ManagedVirtualDevice::class.java).apply {\n                        device = deviceConfig.device\n                        apiLevel = deviceConfig.apiLevel\n                        systemImageSource = deviceConfig.systemImageSource\n                    }\n                }\n            }\n            groups {\n                maybeCreate(\"ci\").apply {\n                    ciDevices.forEach { deviceConfig ->\n                        targetDevices.add(localDevices[deviceConfig.taskName])\n                    }\n                }\n            }\n        }\n    }\n}\n\nprivate data class DeviceConfig(\n    val device: String,\n    val apiLevel: Int,\n    val systemImageSource: String,\n) {\n    val taskName = buildString {\n        append(device.lowercase().replace(\" \", \"\"))\n        append(\"api\")\n        append(apiLevel.toString())\n        append(systemImageSource.replace(\"-\", \"\"))\n    }\n}\n"
  },
  {
    "path": "build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Graph.kt",
    "content": "/*\n * Copyright 2025 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid\n\nimport com.android.utils.associateWithNotNull\nimport com.google.samples.apps.nowinandroid.PluginType.Unknown\nimport org.gradle.api.DefaultTask\nimport org.gradle.api.Project\nimport org.gradle.api.artifacts.Configuration\nimport org.gradle.api.artifacts.ProjectDependency\nimport org.gradle.api.file.RegularFileProperty\nimport org.gradle.api.provider.MapProperty\nimport org.gradle.api.provider.Property\nimport org.gradle.api.tasks.CacheableTask\nimport org.gradle.api.tasks.Input\nimport org.gradle.api.tasks.InputFile\nimport org.gradle.api.tasks.OutputFile\nimport org.gradle.api.tasks.PathSensitive\nimport org.gradle.api.tasks.PathSensitivity.NONE\nimport org.gradle.api.tasks.TaskAction\nimport org.gradle.kotlin.dsl.assign\nimport org.gradle.kotlin.dsl.register\nimport org.gradle.kotlin.dsl.withType\nimport kotlin.text.RegexOption.DOT_MATCHES_ALL\n\n/**\n * Generates module dependency graphs with `graphDump` task, and update the corresponding `README.md` file with `graphUpdate`.\n *\n * This is not an optimal implementation and could be improved if needed:\n * - [Graph.invoke] is **recursively** searching through dependent projects (although in practice it will never reach a stack overflow).\n * - [Graph.invoke] is entirely re-executed for all projects, without re-using intermediate values.\n * - [Graph.invoke] is always executed during Gradle's Configuration phase (but takes in general less than 1 ms for a project).\n *\n * The resulting graphs can be configured with `graph.ignoredProjects` and `graph.supportedConfigurations` properties.\n */\nprivate class Graph(\n    private val root: Project,\n    private val dependencies: MutableMap<Project, Set<Pair<Configuration, Project>>> = mutableMapOf(),\n    private val plugins: MutableMap<Project, PluginType> = mutableMapOf(),\n    private val seen: MutableSet<String> = mutableSetOf(),\n) {\n\n    private val ignoredProjects = root.providers.gradleProperty(\"graph.ignoredProjects\")\n        .map { it.split(\",\").toSet() }\n        .orElse(emptySet())\n    private val supportedConfigurations =\n        root.providers.gradleProperty(\"graph.supportedConfigurations\")\n            .map { it.split(\",\").toSet() }\n            .orElse(setOf(\"api\", \"implementation\", \"baselineProfile\", \"testedApks\"))\n\n    operator fun invoke(project: Project = root): Graph {\n        if (project.path in seen) return this\n        seen += project.path\n        plugins.putIfAbsent(\n            project,\n            PluginType.entries.firstOrNull { project.pluginManager.hasPlugin(it.id) } ?: Unknown,\n        )\n        dependencies.compute(project) { _, u -> u.orEmpty() }\n        project.configurations\n            .matching { it.name in supportedConfigurations.get() }\n            .associateWithNotNull { it.dependencies.withType<ProjectDependency>().ifEmpty { null } }\n            .flatMap { (c, value) -> value.map { dep -> c to project.project(dep.path) } }\n            .filter { (_, p) -> p.path !in ignoredProjects.get() }\n            .forEach { (configuration: Configuration, projectDependency: Project) ->\n                dependencies.compute(project) { _, u -> u.orEmpty() + (configuration to projectDependency) }\n                invoke(projectDependency)\n            }\n        return this\n    }\n\n    fun dependencies(): Map<String, Set<Pair<String, String>>> = dependencies\n        .mapKeys { it.key.path }\n        .mapValues { it.value.mapTo(mutableSetOf()) { (c, p) -> c.name to p.path } }\n\n    fun plugins() = plugins.mapKeys { it.key.path }\n}\n\n/**\n * Declaration order is important, as only the first match will be retained.\n */\ninternal enum class PluginType(val id: String, val ref: String, val style: String) {\n    AndroidApplication(\n        id = \"nowinandroid.android.application\",\n        ref = \"android-application\",\n        style = \"fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000\",\n    ),\n    AndroidFeature(\n        id = \"nowinandroid.android.feature\",\n        ref = \"android-feature\",\n        style = \"fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000\",\n    ),\n    AndroidLibrary(\n        id = \"nowinandroid.android.library\",\n        ref = \"android-library\",\n        style = \"fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000\",\n    ),\n    AndroidTest(\n        id = \"nowinandroid.android.test\",\n        ref = \"android-test\",\n        style = \"fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000\",\n    ),\n    Jvm(\n        id = \"nowinandroid.jvm.library\",\n        ref = \"jvm-library\",\n        style = \"fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000\",\n    ),\n    Unknown(\n        id = \"?\",\n        ref = \"unknown\",\n        style = \"fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000\",\n    ),\n}\n\ninternal fun Project.configureGraphTasks() {\n    if (!buildFile.exists()) return // Ignore root modules without build file\n    val dumpTask = tasks.register<GraphDumpTask>(\"graphDump\") {\n        val graph = Graph(this@configureGraphTasks).invoke()\n        projectPath = this@configureGraphTasks.path\n        dependencies = graph.dependencies()\n        plugins = graph.plugins()\n        output = this@configureGraphTasks.layout.buildDirectory.file(\"mermaid/graph.txt\")\n        legend = this@configureGraphTasks.layout.buildDirectory.file(\"mermaid/legend.txt\")\n    }\n    tasks.register<GraphUpdateTask>(\"graphUpdate\") {\n        projectPath = this@configureGraphTasks.path\n        input = dumpTask.flatMap { it.output }\n        legend = dumpTask.flatMap { it.legend }\n        output = this@configureGraphTasks.layout.projectDirectory.file(\"README.md\")\n    }\n}\n\n@CacheableTask\nprivate abstract class GraphDumpTask : DefaultTask() {\n\n    @get:Input\n    abstract val projectPath: Property<String>\n\n    @get:Input\n    abstract val dependencies: MapProperty<String, Set<Pair<String, String>>>\n\n    @get:Input\n    abstract val plugins: MapProperty<String, PluginType>\n\n    @get:OutputFile\n    abstract val output: RegularFileProperty\n\n    @get:OutputFile\n    abstract val legend: RegularFileProperty\n\n    override fun getDescription() = \"Dumps project dependencies to a mermaid file.\"\n\n    @TaskAction\n    operator fun invoke() {\n        output.get().asFile.writeText(mermaid())\n        legend.get().asFile.writeText(legend())\n        logger.lifecycle(output.get().asFile.toPath().toUri().toString())\n    }\n\n    private fun mermaid() = buildString {\n        val dependencies: Set<Dependency> = dependencies.get()\n            .flatMapTo(mutableSetOf()) { (project, entries) -> entries.map { it.toDependency(project) } }\n        // FrontMatter configuration (not supported yet on GitHub.com)\n        appendLine(\n            // language=YAML\n            \"\"\"\n            ---\n            config:\n              layout: elk\n              elk:\n                nodePlacementStrategy: SIMPLE\n            ---\n            \"\"\".trimIndent(),\n        )\n        // Graph declaration\n        appendLine(\"graph TB\")\n        // Nodes and subgraphs\n        val (rootProjects, nestedProjects) = dependencies\n            .map { listOf(it.project, it.dependency) }.flatten().toSet()\n            .plus(projectPath.get()) // Special case when this specific module has no other dependency\n            .groupBy { it.substringBeforeLast(\":\") }\n            .entries.partition { it.key.isEmpty() }\n\n        val orderedGroups = nestedProjects.groupBy {\n            if (it.key.count { char -> char == ':' } > 1) it.key.substringBeforeLast(\":\") else \"\"\n        }\n\n        orderedGroups.forEach { (outerGroup, innerGroups) ->\n            if (outerGroup.isNotEmpty()) {\n                appendLine(\"  subgraph $outerGroup\")\n                appendLine(\"    direction TB\")\n            }\n            innerGroups.sortedWith(\n                compareBy(\n                    { (group, _) ->\n                        dependencies.filter { dep ->\n                            val toGroup = dep.dependency.substringBeforeLast(\":\")\n                            toGroup == group && dep.project.substringBeforeLast(\":\") != group\n                        }.count()\n                    },\n                    { -it.value.size },\n                ),\n            ).forEach { (group, projects) ->\n                val indent = if (outerGroup.isNotEmpty()) 4 else 2\n                appendLine(\" \".repeat(indent) + \"subgraph $group\")\n                appendLine(\" \".repeat(indent) + \"  direction TB\")\n                projects.sorted().forEach {\n                    appendLine(it.alias(indent = indent + 2, plugins.get().getValue(it)))\n                }\n                appendLine(\" \".repeat(indent) + \"end\")\n            }\n            if (outerGroup.isNotEmpty()) {\n                appendLine(\"  end\")\n            }\n        }\n\n        rootProjects.flatMap { it.value }.sortedDescending().forEach {\n            appendLine(it.alias(indent = 2, plugins.get().getValue(it)))\n        }\n        // Links\n        if (dependencies.isNotEmpty()) appendLine()\n        dependencies\n            .sortedWith(compareBy({ it.project }, { it.dependency }, { it.configuration }))\n            .forEach { appendLine(it.link(indent = 2)) }\n        // Classes\n        appendLine()\n        PluginType.entries.forEach { appendLine(it.classDef()) }\n    }\n\n    private fun legend() = buildString {\n        appendLine(\"graph TB\")\n        listOf(\n            \"application\" to PluginType.AndroidApplication,\n            \"feature\" to PluginType.AndroidFeature,\n            \"library\" to PluginType.AndroidLibrary,\n            \"jvm\" to PluginType.Jvm,\n        ).forEach { (name, type) ->\n            appendLine(name.alias(indent = 2, type))\n        }\n        appendLine()\n        listOf(\n            Dependency(\"application\", \"implementation\", \"feature\"),\n            Dependency(\"library\", \"api\", \"jvm\"),\n        ).forEach {\n            appendLine(it.link(indent = 2))\n        }\n        appendLine()\n        PluginType.entries.forEach { appendLine(it.classDef()) }\n    }\n\n    private class Dependency(val project: String, val configuration: String, val dependency: String)\n\n    private fun Pair<String, String>.toDependency(project: String) =\n        Dependency(project, configuration = first, dependency = second)\n\n    private fun String.alias(indent: Int, pluginType: PluginType): String = buildString {\n        append(\" \".repeat(indent))\n        append(this@alias)\n        append(\"[\").append(substringAfterLast(\":\")).append(\"]:::\")\n        append(pluginType.ref)\n    }\n\n    private fun Dependency.link(indent: Int) = buildString {\n        append(\" \".repeat(indent))\n        append(project).append(\" \")\n        append(\n            when (configuration) {\n                \"api\" -> \"-->\"\n                \"implementation\" -> \"-.->\"\n                else -> \"-.->|$configuration|\"\n            },\n        )\n        append(\" \").append(dependency)\n    }\n\n    private fun PluginType.classDef() = \"classDef $ref $style;\"\n}\n\n@CacheableTask\nprivate abstract class GraphUpdateTask : DefaultTask() {\n\n    @get:Input\n    abstract val projectPath: Property<String>\n\n    @get:InputFile\n    @get:PathSensitive(NONE)\n    abstract val input: RegularFileProperty\n\n    @get:InputFile\n    @get:PathSensitive(NONE)\n    abstract val legend: RegularFileProperty\n\n    @get:OutputFile\n    abstract val output: RegularFileProperty\n\n    override fun getDescription() = \"Updates Markdown file with the corresponding dependency graph.\"\n\n    @TaskAction\n    operator fun invoke() = with(output.get().asFile) {\n        if (!exists()) {\n            createNewFile()\n            writeText(\n                \"\"\"\n                # `${projectPath.get()}`\n\n                ## Module dependency graph\n\n                <!--region graph--> <!--endregion-->\n\n                \"\"\".trimIndent(),\n            )\n        }\n        val mermaid = input.get().asFile.readText().trimTrailingNewLines()\n        val legend = legend.get().asFile.readText().trimTrailingNewLines()\n        val regex = \"\"\"(<!--region graph-->)(.*?)(<!--endregion-->)\"\"\".toRegex(DOT_MATCHES_ALL)\n        val text = readText().replace(regex) { match ->\n            val (start, _, end) = match.destructured\n            \"\"\"\n            |$start\n            |```mermaid\n            |$mermaid\n            |```\n            |\n            |<details><summary>📋 Graph legend</summary>\n            |\n            |```mermaid\n            |$legend\n            |```\n            |\n            |</details>\n            |$end\n            \"\"\".trimMargin()\n        }\n        writeText(text)\n    }\n\n    private fun String.trimTrailingNewLines() = lines()\n        .dropLastWhile(String::isBlank)\n        .joinToString(System.lineSeparator())\n}\n"
  },
  {
    "path": "build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Jacoco.kt",
    "content": "/*\n * Copyright 2024 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid\n\nimport com.android.build.api.artifact.ScopedArtifact\nimport com.android.build.api.dsl.CommonExtension\nimport com.android.build.api.variant.AndroidComponentsExtension\nimport com.android.build.api.variant.ScopedArtifacts\nimport com.android.build.api.variant.SourceDirectories\nimport org.gradle.api.Project\nimport org.gradle.api.file.Directory\nimport org.gradle.api.file.RegularFile\nimport org.gradle.api.provider.ListProperty\nimport org.gradle.api.provider.Provider\nimport org.gradle.api.tasks.testing.Test\nimport org.gradle.kotlin.dsl.assign\nimport org.gradle.kotlin.dsl.configure\nimport org.gradle.kotlin.dsl.register\nimport org.gradle.kotlin.dsl.withType\nimport org.gradle.testing.jacoco.plugins.JacocoPluginExtension\nimport org.gradle.testing.jacoco.plugins.JacocoTaskExtension\nimport org.gradle.testing.jacoco.tasks.JacocoReport\nimport java.util.Locale\n\nprivate val coverageExclusions = listOf(\n    // Android\n    \"**/R.class\",\n    \"**/R\\$*.class\",\n    \"**/BuildConfig.*\",\n    \"**/Manifest*.*\",\n    \"**/*_Hilt*.class\",\n    \"**/Hilt_*.class\",\n)\n\nprivate fun String.capitalize() = replaceFirstChar {\n    if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString()\n}\n\n/**\n * Creates a new task that generates a combined coverage report with data from local and\n * instrumented tests.\n *\n * `create{variant}CombinedCoverageReport`\n *\n * Note that coverage data must exist before running the task. This allows us to run device\n * tests on CI using a different Github Action or an external device farm.\n */\ninternal fun Project.configureJacoco(\n    commonExtension: CommonExtension,\n    androidComponentsExtension: AndroidComponentsExtension<*, *, *>,\n) {\n    // Configure only the debug build, otherwise it will force the debuggable flag on release buildTypes as well\n    commonExtension.buildTypes.named(\"debug\") {\n        enableAndroidTestCoverage = true\n        enableUnitTestCoverage = true\n    }\n\n    configure<JacocoPluginExtension> {\n        toolVersion = libs.findVersion(\"jacoco\").get().toString()\n    }\n\n    androidComponentsExtension.onVariants { variant ->\n        val myObjFactory = project.objects\n        val buildDir = layout.buildDirectory.get().asFile\n        val allJars: ListProperty<RegularFile> = myObjFactory.listProperty(RegularFile::class.java)\n        val allDirectories: ListProperty<Directory> =\n            myObjFactory.listProperty(Directory::class.java)\n        val reportTask =\n            tasks.register(\n                \"create${variant.name.capitalize()}CombinedCoverageReport\",\n                JacocoReport::class,\n            ) {\n                classDirectories.setFrom(\n                    allJars,\n                    allDirectories.map { dirs ->\n                        dirs.map { dir ->\n                            myObjFactory.fileTree().setDir(dir).exclude(coverageExclusions)\n                        }\n                    },\n                )\n                reports {\n                    xml.required = true\n                    html.required = true\n                }\n\n                fun SourceDirectories.Flat?.toFilePaths(): Provider<List<String>> = this\n                    ?.all\n                    ?.map { directories -> directories.map { it.asFile.path } }\n                    ?: provider { emptyList() }\n                sourceDirectories.setFrom(\n                    files(\n                        variant.sources.java.toFilePaths(),\n                        variant.sources.kotlin.toFilePaths(),\n                    ),\n                )\n\n                executionData.setFrom(\n                    project.fileTree(\"$buildDir/outputs/unit_test_code_coverage/${variant.name}UnitTest\")\n                        .matching { include(\"**/*.exec\") },\n\n                    project.fileTree(\"$buildDir/outputs/code_coverage/${variant.name}AndroidTest\")\n                        .matching { include(\"**/*.ec\") },\n                )\n            }\n\n        variant.artifacts.forScope(ScopedArtifacts.Scope.PROJECT)\n            .use(reportTask)\n            .toGet(\n                ScopedArtifact.CLASSES,\n                { _ -> allJars },\n                { _ -> allDirectories },\n            )\n    }\n\n    tasks.withType<Test>().configureEach {\n        configure<JacocoTaskExtension> {\n            // Required for JaCoCo + Robolectric\n            // https://github.com/robolectric/robolectric/issues/2230\n            isIncludeNoLocationClasses = true\n\n            // Required for JDK 11 with the above\n            // https://github.com/gradle/gradle/issues/5184#issuecomment-391982009\n            excludes = listOf(\"jdk.internal.*\")\n        }\n    }\n}\n"
  },
  {
    "path": "build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid\n\nimport com.android.build.api.dsl.CommonExtension\nimport org.gradle.api.JavaVersion\nimport org.gradle.api.Project\nimport org.gradle.api.plugins.JavaPluginExtension\nimport org.gradle.kotlin.dsl.assign\nimport org.gradle.kotlin.dsl.configure\nimport org.gradle.kotlin.dsl.dependencies\nimport org.jetbrains.kotlin.gradle.dsl.JvmTarget\nimport org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension\nimport org.jetbrains.kotlin.gradle.dsl.KotlinBaseExtension\nimport org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension\n\n/**\n * Configure base Kotlin with Android options\n */\ninternal fun Project.configureKotlinAndroid(\n    commonExtension: CommonExtension,\n) {\n    commonExtension.apply {\n        compileSdk = 36\n\n        defaultConfig.apply {\n            minSdk = 23\n        }\n\n        compileOptions.apply {\n            // Up to Java 11 APIs are available through desugaring\n            // https://developer.android.com/studio/write/java11-minimal-support-table\n            sourceCompatibility = JavaVersion.VERSION_11\n            targetCompatibility = JavaVersion.VERSION_11\n            isCoreLibraryDesugaringEnabled = true\n        }\n    }\n\n    configureKotlin<KotlinAndroidProjectExtension>()\n\n    dependencies {\n        \"coreLibraryDesugaring\"(libs.findLibrary(\"android.desugarJdkLibs\").get())\n    }\n}\n\n/**\n * Configure base Kotlin options for JVM (non-Android)\n */\ninternal fun Project.configureKotlinJvm() {\n    extensions.configure<JavaPluginExtension> {\n        // Up to Java 11 APIs are available through desugaring\n        // https://developer.android.com/studio/write/java11-minimal-support-table\n        sourceCompatibility = JavaVersion.VERSION_11\n        targetCompatibility = JavaVersion.VERSION_11\n    }\n\n    configureKotlin<KotlinJvmProjectExtension>()\n}\n\n/**\n * Configure base Kotlin options\n */\nprivate inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() = configure<T> {\n    // Treat all Kotlin warnings as errors (disabled by default)\n    // Override by setting warningsAsErrors=true in your ~/.gradle/gradle.properties\n    val warningsAsErrors = providers.gradleProperty(\"warningsAsErrors\").map {\n        it.toBoolean()\n    }.orElse(false)\n    when (this) {\n        is KotlinAndroidProjectExtension -> compilerOptions\n        is KotlinJvmProjectExtension -> compilerOptions\n        else -> TODO(\"Unsupported project extension $this ${T::class}\")\n    }.apply {\n        jvmTarget = JvmTarget.JVM_11\n        allWarningsAsErrors = warningsAsErrors\n        freeCompilerArgs.add(\n            // Enable experimental coroutines APIs, including Flow\n            \"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi\",\n        )\n        freeCompilerArgs.add(\n            /**\n             * Remove this args after Phase 3.\n             * https://kotlinlang.org/api/latest/jvm/stdlib/kotlin/-consistent-copy-visibility/#deprecation-timeline\n             *\n             * Deprecation timeline\n             * Phase 3. (Supposedly Kotlin 2.2 or Kotlin 2.3).\n             * The default changes.\n             * Unless ExposedCopyVisibility is used, the generated 'copy' method has the same visibility as the primary constructor.\n             * The binary signature changes. The error on the declaration is no longer reported.\n             * '-Xconsistent-data-class-copy-visibility' compiler flag and ConsistentCopyVisibility annotation are now unnecessary.\n             */\n            \"-Xconsistent-data-class-copy-visibility\",\n        )\n    }\n}\n"
  },
  {
    "path": "build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaBuildType.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid\n\n/**\n * This is shared between :app and :benchmarks module to provide configurations type safety.\n */\nenum class NiaBuildType(val applicationIdSuffix: String? = null) {\n    DEBUG(\".debug\"),\n    RELEASE,\n}\n"
  },
  {
    "path": "build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaFlavor.kt",
    "content": "/*\n * Copyright 2026 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid\n\nimport com.android.build.api.dsl.ApplicationExtension\nimport com.android.build.api.dsl.ApplicationProductFlavor\nimport com.android.build.api.dsl.CommonExtension\nimport com.android.build.api.dsl.ProductFlavor\nimport org.gradle.kotlin.dsl.invoke\n\n@Suppress(\"EnumEntryName\")\nenum class FlavorDimension {\n    contentType,\n}\n\n// The content for the app can either come from local static data which is useful for demo\n// purposes, or from a production backend server which supplies up-to-date, real content.\n// These two product flavors reflect this behaviour.\n@Suppress(\"EnumEntryName\")\nenum class NiaFlavor(val dimension: FlavorDimension, val applicationIdSuffix: String? = null) {\n    demo(FlavorDimension.contentType, applicationIdSuffix = \".demo\"),\n    prod(FlavorDimension.contentType),\n}\n\nfun configureFlavors(\n    commonExtension: CommonExtension,\n    flavorConfigurationBlock: ProductFlavor.(flavor: NiaFlavor) -> Unit = {},\n) {\n    commonExtension.apply {\n        FlavorDimension.entries.forEach { flavorDimension ->\n            flavorDimensions += flavorDimension.name\n        }\n\n        productFlavors {\n            NiaFlavor.entries.forEach { niaFlavor ->\n                register(niaFlavor.name) {\n                    dimension = niaFlavor.dimension.name\n                    flavorConfigurationBlock(this, niaFlavor)\n                    if (commonExtension is ApplicationExtension && this is ApplicationProductFlavor) {\n                        if (niaFlavor.applicationIdSuffix != null) {\n                            applicationIdSuffix = niaFlavor.applicationIdSuffix\n                        }\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/PrintTestApks.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid\n\nimport com.android.build.api.artifact.SingleArtifact\nimport com.android.build.api.variant.AndroidComponentsExtension\nimport com.android.build.api.variant.BuiltArtifactsLoader\nimport com.android.build.api.variant.HasAndroidTest\nimport org.gradle.api.DefaultTask\nimport org.gradle.api.Project\nimport org.gradle.api.file.Directory\nimport org.gradle.api.file.DirectoryProperty\nimport org.gradle.api.provider.ListProperty\nimport org.gradle.api.provider.Property\nimport org.gradle.api.tasks.Input\nimport org.gradle.api.tasks.InputDirectory\nimport org.gradle.api.tasks.InputFiles\nimport org.gradle.api.tasks.Internal\nimport org.gradle.api.tasks.PathSensitive\nimport org.gradle.api.tasks.PathSensitivity\nimport org.gradle.api.tasks.TaskAction\nimport org.gradle.kotlin.dsl.assign\nimport org.gradle.work.DisableCachingByDefault\nimport java.io.File\n\ninternal fun Project.configurePrintApksTask(extension: AndroidComponentsExtension<*, *, *>) {\n    extension.onVariants { variant ->\n        if (variant is HasAndroidTest) {\n            val loader = variant.artifacts.getBuiltArtifactsLoader()\n            val artifact = variant.androidTest?.artifacts?.get(SingleArtifact.APK)\n            val javaSources = variant.androidTest?.sources?.java?.all\n            val kotlinSources = variant.androidTest?.sources?.kotlin?.all\n\n            val testSources = if (javaSources != null && kotlinSources != null) {\n                javaSources.zip(kotlinSources) { javaDirs, kotlinDirs ->\n                    javaDirs + kotlinDirs\n                }\n            } else {\n                javaSources ?: kotlinSources\n            }\n\n            if (artifact != null && testSources != null) {\n                tasks.register(\n                    \"${variant.name}PrintTestApk\",\n                    PrintApkLocationTask::class.java,\n                ) {\n                    apkFolder = artifact\n                    builtArtifactsLoader = loader\n                    variantName = variant.name\n                    sources = testSources\n                }\n            }\n        }\n    }\n}\n\n@DisableCachingByDefault(because = \"Prints output\")\ninternal abstract class PrintApkLocationTask : DefaultTask() {\n\n    @get:PathSensitive(PathSensitivity.RELATIVE)\n    @get:InputDirectory\n    abstract val apkFolder: DirectoryProperty\n\n    @get:PathSensitive(PathSensitivity.RELATIVE)\n    @get:InputFiles\n    abstract val sources: ListProperty<Directory>\n\n    @get:Internal\n    abstract val builtArtifactsLoader: Property<BuiltArtifactsLoader>\n\n    @get:Input\n    abstract val variantName: Property<String>\n\n    @TaskAction\n    fun taskAction() {\n        val hasFiles = sources.orNull?.any { directory ->\n            directory.asFileTree.files.any {\n                it.isFile && \"build${File.separator}generated\" !in it.parentFile.path\n            }\n        } ?: throw RuntimeException(\"Cannot check androidTest sources\")\n\n        // Don't print APK location if there are no androidTest source files\n        if (!hasFiles) return\n\n        val builtArtifacts = builtArtifactsLoader.get().load(apkFolder.get())\n            ?: throw RuntimeException(\"Cannot load APKs\")\n        if (builtArtifacts.elements.size != 1) {\n            throw RuntimeException(\"Expected one APK !\")\n        }\n        val apk = File(builtArtifacts.elements.single().outputFile).toPath()\n        println(apk)\n    }\n}\n"
  },
  {
    "path": "build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/ProjectExtensions.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid\n\nimport org.gradle.api.Project\nimport org.gradle.api.artifacts.VersionCatalog\nimport org.gradle.api.artifacts.VersionCatalogsExtension\nimport org.gradle.kotlin.dsl.getByType\n\nval Project.libs\n    get(): VersionCatalog = extensions.getByType<VersionCatalogsExtension>().named(\"libs\")\n"
  },
  {
    "path": "build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Spotless.kt",
    "content": "/*\n * Copyright 2026 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid\n\nimport com.diffplug.gradle.spotless.SpotlessExtension\nimport org.gradle.api.Project\nimport org.gradle.kotlin.dsl.apply\nimport org.gradle.kotlin.dsl.configure\n\ninternal fun Project.configureSpotlessForAndroid() {\n    configureSpotlessCommon()\n    extensions.configure<SpotlessExtension> {\n        format(\"xml\") {\n            target(\"src/**/*.xml\")\n            // Look for the first XML tag that isn't a comment (<!--) or the xml declaration (<?xml)\n            licenseHeaderFile(rootDir.resolve(\"spotless/copyright.xml\"), \"(<[^!?])\")\n            endWithNewline()\n        }\n    }\n}\n\ninternal fun Project.configureSpotlessForJvm() {\n    configureSpotlessCommon()\n}\n\ninternal fun Project.configureSpotlessForRootProject() {\n    apply(plugin = \"com.diffplug.spotless\")\n    extensions.configure<SpotlessExtension> {\n        kotlin {\n            target(\"build-logic/convention/src/**/*.kt\")\n            ktlint(libs.findVersion(\"ktlint\").get().requiredVersion).editorConfigOverride(\n                mapOf(\"android\" to \"true\"),\n            )\n            licenseHeaderFile(rootDir.resolve(\"spotless/copyright.kt\"))\n            endWithNewline()\n        }\n        format(\"kts\") {\n            target(\"*.kts\")\n            target(\"build-logic/*.kts\")\n            target(\"build-logic/convention/*.kts\")\n            // Look for the first line that doesn't have a block comment (assumed to be the license)\n            licenseHeaderFile(rootDir.resolve(\"spotless/copyright.kts\"), \"(^(?![\\\\/ ]\\\\*).*$)\")\n            endWithNewline()\n        }\n    }\n}\n\nprivate fun Project.configureSpotlessCommon() {\n    apply(plugin = \"com.diffplug.spotless\")\n    extensions.configure<SpotlessExtension> {\n        kotlin {\n            target(\"src/**/*.kt\")\n            ktlint(libs.findVersion(\"ktlint\").get().requiredVersion).editorConfigOverride(\n                mapOf(\"android\" to \"true\"),\n            )\n            licenseHeaderFile(rootDir.resolve(\"spotless/copyright.kt\"))\n            endWithNewline()\n        }\n        format(\"kts\") {\n            target(\"*.kts\")\n            // Look for the first line that doesn't have a block comment (assumed to be the license)\n            licenseHeaderFile(rootDir.resolve(\"spotless/copyright.kts\"), \"(^(?![\\\\/ ]\\\\*).*$)\")\n            endWithNewline()\n        }\n    }\n}\n"
  },
  {
    "path": "build-logic/gradle.properties",
    "content": "# Gradle properties are not passed to included builds https://github.com/gradle/gradle/issues/2534\norg.gradle.parallel=true\norg.gradle.caching=true\norg.gradle.configureondemand=true\norg.gradle.configuration-cache=true\norg.gradle.configuration-cache.parallel=true\n"
  },
  {
    "path": "build-logic/settings.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npluginManagement {\n    repositories {\n        gradlePluginPortal()\n        google()\n    }\n}\n\ndependencyResolutionManagement {\n    repositories {\n        google {\n            content {\n                includeGroupByRegex(\"com\\\\.android.*\")\n                includeGroupByRegex(\"com\\\\.google.*\")\n                includeGroupByRegex(\"androidx.*\")\n            }\n        }\n        mavenCentral()\n    }\n    versionCatalogs {\n        create(\"libs\") {\n            from(files(\"../gradle/libs.versions.toml\"))\n        }\n    }\n}\n\nrootProject.name = \"build-logic\"\ninclude(\":convention\")\n"
  },
  {
    "path": "build.gradle.kts",
    "content": "/*\n * Copyright 2021 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*\n * By listing all the plugins used throughout all subprojects in the root project build script, it\n * ensures that the build script classpath remains the same for all projects. This avoids potential\n * problems with mismatching versions of transitive plugin dependencies. A subproject that applies\n * an unlisted plugin will have that plugin and its dependencies _appended_ to the classpath, not\n * replacing pre-existing dependencies.\n */\nplugins {\n    alias(libs.plugins.android.application) apply false\n    alias(libs.plugins.android.library) apply false\n    alias(libs.plugins.android.test) apply false\n    alias(libs.plugins.baselineprofile) apply false\n    alias(libs.plugins.compose) apply false\n    alias(libs.plugins.kotlin.jvm) apply false\n    alias(libs.plugins.kotlin.serialization) apply false\n    alias(libs.plugins.dependencyGuard) apply false\n    alias(libs.plugins.firebase.crashlytics) apply false\n    alias(libs.plugins.firebase.perf) apply false\n    alias(libs.plugins.gms) apply false\n    alias(libs.plugins.hilt) apply false\n    alias(libs.plugins.ksp) apply false\n    alias(libs.plugins.roborazzi) apply false\n    alias(libs.plugins.google.osslicenses) apply false\n    alias(libs.plugins.room) apply false\n    alias(libs.plugins.spotless) apply false\n    alias(libs.plugins.nowinandroid.root)\n}\n"
  },
  {
    "path": "build_android_release.sh",
    "content": "#!/usr/bin/env bash\n\n#\n# Copyright 2022 The Android Open Source Project\n#\n#   Licensed under the Apache License, Version 2.0 (the \"License\");\n#   you may not use this file except in compliance with the License.\n#   You may obtain a copy of the License at\n#\n#       https://www.apache.org/licenses/LICENSE-2.0\n#\n#   Unless required by applicable law or agreed to in writing, software\n#   distributed under the License is distributed on an \"AS IS\" BASIS,\n#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n#   See the License for the specific language governing permissions and\n#   limitations under the License.\n#\n\n# IGNORE this file, it's only used in the internal Google release process\n\nDIR=\"$( cd \"$( dirname \"${BASH_SOURCE[0]}\" )\" && pwd )\"\nAPP_OUT=$DIR/app/build/outputs\n\nexport JAVA_HOME=\"$(cd $DIR/../nowinandroid-prebuilts/jdk17/linux && pwd )\"\necho \"JAVA_HOME=$JAVA_HOME\"\n\nexport ANDROID_HOME=\"$(cd $DIR/../../../prebuilts/fullsdk/linux && pwd )\"\necho \"ANDROID_HOME=$ANDROID_HOME\"\n\necho \"Copying google-services.json\"\ncp $DIR/../nowinandroid-prebuilts/google-services.json $DIR/app\n\necho \"Copying local.properties\"\ncp $DIR/../nowinandroid-prebuilts/local.properties $DIR\n\ncd $DIR\n\n# Build the prodRelease variant\nGRADLE_PARAMS=\" --stacktrace -Puse-google-services\"\n$DIR/gradlew :app:clean :app:assembleProdRelease :app:bundleProdRelease ${GRADLE_PARAMS}\nBUILD_RESULT=$?\n\n# Prod release apk\ncp $APP_OUT/apk/prod/release/app-prod-release.apk $DIST_DIR/app-prod-release.apk\n# Prod release bundle\ncp $APP_OUT/bundle/prodRelease/app-prod-release.aab $DIST_DIR/app-prod-release.aab\n# Prod release bundle mapping\ncp $APP_OUT/mapping/prodRelease/mapping.txt $DIST_DIR/mobile-release-aab-mapping.txt\n\nexit $BUILD_RESULT\n"
  },
  {
    "path": "compose_compiler_config.conf",
    "content": "// This file contains classes (with possible wildcards) that the Compose Compiler will treat as stable.\n// It allows us to define classes that are not part of our codebase without wrapping them in a stable class.\n// For more information, check https://developer.android.com/jetpack/compose/performance/stability/fix#configuration-file\n\n// We always use immutable classes for our data model, to avoid running the Compose compiler\n// in the module we declare it to be stable here.\ncom.google.samples.apps.nowinandroid.core.model.data.*\n\n// Java standard library classes\njava.time.ZoneId\njava.time.ZoneOffset\n"
  },
  {
    "path": "core/analytics/.gitignore",
    "content": "/build"
  },
  {
    "path": "core/analytics/README.md",
    "content": "# `:core:analytics`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :core\n    direction TB\n    :core:analytics[analytics]:::android-library\n  end\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "core/analytics/build.gradle.kts",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nplugins {\n    alias(libs.plugins.nowinandroid.android.library)\n    alias(libs.plugins.nowinandroid.android.library.compose)\n    alias(libs.plugins.nowinandroid.hilt)\n}\n\nandroid {\n    namespace = \"com.google.samples.apps.nowinandroid.core.analytics\"\n}\n\ndependencies {\n    implementation(libs.androidx.compose.runtime)\n\n    prodImplementation(platform(libs.firebase.bom))\n    prodImplementation(libs.firebase.analytics)\n}\n"
  },
  {
    "path": "core/analytics/src/demo/kotlin/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.analytics\n\nimport dagger.Binds\nimport dagger.Module\nimport dagger.hilt.InstallIn\nimport dagger.hilt.components.SingletonComponent\n\n@Module\n@InstallIn(SingletonComponent::class)\ninternal abstract class AnalyticsModule {\n    @Binds\n    abstract fun bindsAnalyticsHelper(analyticsHelperImpl: StubAnalyticsHelper): AnalyticsHelper\n}\n"
  },
  {
    "path": "core/analytics/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2023 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<manifest />\n"
  },
  {
    "path": "core/analytics/src/main/kotlin/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsEvent.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.analytics\n\n/**\n * Represents an analytics event.\n *\n * @param type - the event type. Wherever possible use one of the standard\n * event `Types`, however, if there is no suitable event type already defined, a custom event can be\n * defined as long as it is configured in your backend analytics system (for example, by creating a\n * Firebase Analytics custom event).\n *\n * @param extras - list of parameters which supply additional context to the event. See `Param`.\n */\ndata class AnalyticsEvent(\n    val type: String,\n    val extras: List<Param> = emptyList(),\n) {\n    // Standard analytics types.\n    class Types {\n        companion object {\n            const val SCREEN_VIEW = \"screen_view\" // (extras: SCREEN_NAME)\n        }\n    }\n\n    /**\n     * A key-value pair used to supply extra context to an analytics event.\n     *\n     * @param key - the parameter key. Wherever possible use one of the standard `ParamKeys`,\n     * however, if no suitable key is available you can define your own as long as it is configured\n     * in your backend analytics system (for example, by creating a Firebase Analytics custom\n     * parameter).\n     *\n     * @param value - the parameter value.\n     */\n    data class Param(val key: String, val value: String)\n\n    // Standard parameter keys.\n    class ParamKeys {\n        companion object {\n            const val SCREEN_NAME = \"screen_name\"\n        }\n    }\n}\n"
  },
  {
    "path": "core/analytics/src/main/kotlin/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsHelper.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.analytics\n\n/**\n * Interface for logging analytics events. See `FirebaseAnalyticsHelper` and\n * `StubAnalyticsHelper` for implementations.\n */\ninterface AnalyticsHelper {\n    fun logEvent(event: AnalyticsEvent)\n}\n"
  },
  {
    "path": "core/analytics/src/main/kotlin/com/google/samples/apps/nowinandroid/core/analytics/NoOpAnalyticsHelper.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.analytics\n\n/**\n * Implementation of AnalyticsHelper which does nothing. Useful for tests and previews.\n */\nclass NoOpAnalyticsHelper : AnalyticsHelper {\n    override fun logEvent(event: AnalyticsEvent) = Unit\n}\n"
  },
  {
    "path": "core/analytics/src/main/kotlin/com/google/samples/apps/nowinandroid/core/analytics/StubAnalyticsHelper.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.analytics\n\nimport android.util.Log\nimport javax.inject.Inject\nimport javax.inject.Singleton\n\nprivate const val TAG = \"StubAnalyticsHelper\"\n\n/**\n * An implementation of AnalyticsHelper just writes the events to logcat. Used in builds where no\n * analytics events should be sent to a backend.\n */\n@Singleton\ninternal class StubAnalyticsHelper @Inject constructor() : AnalyticsHelper {\n    override fun logEvent(event: AnalyticsEvent) {\n        Log.d(TAG, \"Received analytics event: $event\")\n    }\n}\n"
  },
  {
    "path": "core/analytics/src/main/kotlin/com/google/samples/apps/nowinandroid/core/analytics/UiHelpers.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.analytics\n\nimport androidx.compose.runtime.staticCompositionLocalOf\n\n/**\n * Global key used to obtain access to the AnalyticsHelper through a CompositionLocal.\n */\nval LocalAnalyticsHelper = staticCompositionLocalOf<AnalyticsHelper> {\n    // Provide a default AnalyticsHelper which does nothing. This is so that tests and previews\n    // do not have to provide one. For real app builds provide a different implementation.\n    NoOpAnalyticsHelper()\n}\n"
  },
  {
    "path": "core/analytics/src/prod/kotlin/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.analytics\n\nimport com.google.firebase.Firebase\nimport com.google.firebase.analytics.FirebaseAnalytics\nimport com.google.firebase.analytics.analytics\nimport dagger.Binds\nimport dagger.Module\nimport dagger.Provides\nimport dagger.hilt.InstallIn\nimport dagger.hilt.components.SingletonComponent\nimport javax.inject.Singleton\n\n@Module\n@InstallIn(SingletonComponent::class)\ninternal abstract class AnalyticsModule {\n    @Binds\n    abstract fun bindsAnalyticsHelper(analyticsHelperImpl: FirebaseAnalyticsHelper): AnalyticsHelper\n\n    companion object {\n        @Provides\n        @Singleton\n        fun provideFirebaseAnalytics(): FirebaseAnalytics = Firebase.analytics\n    }\n}\n"
  },
  {
    "path": "core/analytics/src/prod/kotlin/com/google/samples/apps/nowinandroid/core/analytics/FirebaseAnalyticsHelper.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.analytics\n\nimport com.google.firebase.analytics.FirebaseAnalytics\nimport com.google.firebase.analytics.logEvent\nimport javax.inject.Inject\n\n/**\n * Implementation of `AnalyticsHelper` which logs events to a Firebase backend.\n */\ninternal class FirebaseAnalyticsHelper @Inject constructor(\n    private val firebaseAnalytics: FirebaseAnalytics,\n) : AnalyticsHelper {\n\n    override fun logEvent(event: AnalyticsEvent) {\n        firebaseAnalytics.logEvent(event.type) {\n            for (extra in event.extras) {\n                // Truncate parameter keys and values according to firebase maximum length values.\n                param(\n                    key = extra.key.take(40),\n                    value = extra.value.take(100),\n                )\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "core/common/.gitignore",
    "content": "/build"
  },
  {
    "path": "core/common/README.md",
    "content": "# `:core:common`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :core\n    direction TB\n    :core:common[common]:::jvm-library\n  end\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "core/common/build.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nplugins {\n    alias(libs.plugins.nowinandroid.jvm.library)\n    alias(libs.plugins.nowinandroid.hilt)\n}\n\ndependencies {\n    implementation(libs.kotlinx.coroutines.core)\n    testImplementation(libs.kotlinx.coroutines.test)\n    testImplementation(libs.turbine)\n}\n"
  },
  {
    "path": "core/common/src/main/kotlin/com/google/samples/apps/nowinandroid/core/common/network/NiaDispatchers.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.common.network\n\nimport javax.inject.Qualifier\nimport kotlin.annotation.AnnotationRetention.RUNTIME\n\n@Qualifier\n@Retention(RUNTIME)\nannotation class Dispatcher(val niaDispatcher: NiaDispatchers)\n\nenum class NiaDispatchers {\n    Default,\n    IO,\n}\n"
  },
  {
    "path": "core/common/src/main/kotlin/com/google/samples/apps/nowinandroid/core/common/network/di/CoroutineScopesModule.kt",
    "content": "/*\n * Copyright 2026 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.common.network.di\n\nimport com.google.samples.apps.nowinandroid.core.common.network.Dispatcher\nimport com.google.samples.apps.nowinandroid.core.common.network.NiaDispatchers.Default\nimport dagger.Module\nimport dagger.Provides\nimport dagger.hilt.InstallIn\nimport dagger.hilt.components.SingletonComponent\nimport kotlinx.coroutines.CoroutineDispatcher\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.SupervisorJob\nimport javax.inject.Qualifier\nimport javax.inject.Singleton\n\n@Retention(AnnotationRetention.RUNTIME)\n@Qualifier\nannotation class ApplicationScope\n\n@Module\n@InstallIn(SingletonComponent::class)\ninternal object CoroutineScopesModule {\n    @Provides\n    @Singleton\n    @ApplicationScope\n    fun providesCoroutineScope(\n        @Dispatcher(Default) dispatcher: CoroutineDispatcher,\n    ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)\n}\n"
  },
  {
    "path": "core/common/src/main/kotlin/com/google/samples/apps/nowinandroid/core/common/network/di/DispatchersModule.kt",
    "content": "/*\n * Copyright 2026 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.common.network.di\n\nimport com.google.samples.apps.nowinandroid.core.common.network.Dispatcher\nimport com.google.samples.apps.nowinandroid.core.common.network.NiaDispatchers.Default\nimport com.google.samples.apps.nowinandroid.core.common.network.NiaDispatchers.IO\nimport dagger.Module\nimport dagger.Provides\nimport dagger.hilt.InstallIn\nimport dagger.hilt.components.SingletonComponent\nimport kotlinx.coroutines.CoroutineDispatcher\nimport kotlinx.coroutines.Dispatchers\n\n@Module\n@InstallIn(SingletonComponent::class)\nobject DispatchersModule {\n    @Provides\n    @Dispatcher(IO)\n    fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO\n\n    @Provides\n    @Dispatcher(Default)\n    fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default\n}\n"
  },
  {
    "path": "core/common/src/main/kotlin/com/google/samples/apps/nowinandroid/core/common/result/Result.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.common.result\n\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.catch\nimport kotlinx.coroutines.flow.map\nimport kotlinx.coroutines.flow.onStart\n\nsealed interface Result<out T> {\n    data class Success<T>(val data: T) : Result<T>\n    data class Error(val exception: Throwable) : Result<Nothing>\n    data object Loading : Result<Nothing>\n}\n\nfun <T> Flow<T>.asResult(): Flow<Result<T>> = map<T, Result<T>> { Result.Success(it) }\n    .onStart { emit(Result.Loading) }\n    .catch { emit(Result.Error(it)) }\n"
  },
  {
    "path": "core/common/src/test/kotlin/com/google/samples/apps/nowinandroid/core/common/result/ResultKtTest.kt",
    "content": "/*\n * Copyright 2026 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.common.result\n\nimport app.cash.turbine.test\nimport kotlinx.coroutines.flow.flow\nimport kotlinx.coroutines.test.runTest\nimport org.junit.Test\nimport kotlin.test.assertEquals\n\nclass ResultKtTest {\n\n    @Test\n    fun Result_catches_errors() = runTest {\n        flow {\n            emit(1)\n            throw Exception(\"Test Done\")\n        }\n            .asResult()\n            .test {\n                assertEquals(Result.Loading, awaitItem())\n                assertEquals(Result.Success(1), awaitItem())\n\n                when (val errorResult = awaitItem()) {\n                    is Result.Error -> assertEquals(\n                        \"Test Done\",\n                        errorResult.exception.message,\n                    )\n\n                    Result.Loading,\n                    is Result.Success,\n                    -> throw IllegalStateException(\n                        \"The flow should have emitted an Error Result\",\n                    )\n                }\n\n                awaitComplete()\n            }\n    }\n}\n"
  },
  {
    "path": "core/data/.gitignore",
    "content": "/build"
  },
  {
    "path": "core/data/README.md",
    "content": "# `:core:data`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :core\n    direction TB\n    :core:analytics[analytics]:::android-library\n    :core:common[common]:::jvm-library\n    :core:data[data]:::android-library\n    :core:database[database]:::android-library\n    :core:datastore[datastore]:::android-library\n    :core:datastore-proto[datastore-proto]:::jvm-library\n    :core:model[model]:::jvm-library\n    :core:network[network]:::android-library\n    :core:notifications[notifications]:::android-library\n  end\n\n  :core:data -.-> :core:analytics\n  :core:data --> :core:common\n  :core:data --> :core:database\n  :core:data --> :core:datastore\n  :core:data --> :core:network\n  :core:data -.-> :core:notifications\n  :core:database --> :core:model\n  :core:datastore -.-> :core:common\n  :core:datastore --> :core:datastore-proto\n  :core:datastore --> :core:model\n  :core:network --> :core:common\n  :core:network --> :core:model\n  :core:notifications -.-> :core:common\n  :core:notifications --> :core:model\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "core/data/build.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nplugins {\n    alias(libs.plugins.nowinandroid.android.library)\n    alias(libs.plugins.nowinandroid.android.library.jacoco)\n    alias(libs.plugins.nowinandroid.hilt)\n    id(\"kotlinx-serialization\")\n}\n\nandroid {\n    namespace = \"com.google.samples.apps.nowinandroid.core.data\"\n    testOptions.unitTests.isIncludeAndroidResources = true\n}\n\ndependencies {\n    api(projects.core.common)\n    api(projects.core.database)\n    api(projects.core.datastore)\n    api(projects.core.network)\n\n    implementation(projects.core.analytics)\n    implementation(projects.core.notifications)\n\n    testImplementation(libs.kotlinx.coroutines.test)\n    testImplementation(libs.kotlinx.serialization.json)\n    testImplementation(projects.core.datastoreTest)\n    testImplementation(projects.core.testing)\n}\n"
  },
  {
    "path": "core/data/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\" />\n</manifest>\n"
  },
  {
    "path": "core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/SyncUtilities.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data\n\nimport android.util.Log\nimport com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions\nimport com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList\nimport kotlin.coroutines.cancellation.CancellationException\n\n/**\n * Interface marker for a class that manages synchronization between local data and a remote\n * source for a [Syncable].\n */\ninterface Synchronizer {\n    suspend fun getChangeListVersions(): ChangeListVersions\n\n    suspend fun updateChangeListVersions(update: ChangeListVersions.() -> ChangeListVersions)\n\n    /**\n     * Syntactic sugar to call [Syncable.syncWith] while omitting the synchronizer argument\n     */\n    suspend fun Syncable.sync() = this@sync.syncWith(this@Synchronizer)\n}\n\n/**\n * Interface marker for a class that is synchronized with a remote source. Syncing must not be\n * performed concurrently and it is the [Synchronizer]'s responsibility to ensure this.\n */\ninterface Syncable {\n    /**\n     * Synchronizes the local database backing the repository with the network.\n     * Returns if the sync was successful or not.\n     */\n    suspend fun syncWith(synchronizer: Synchronizer): Boolean\n}\n\n/**\n * Attempts [block], returning a successful [Result] if it succeeds, otherwise a [Result.Failure]\n * taking care not to break structured concurrency\n */\nprivate suspend fun <T> suspendRunCatching(block: suspend () -> T): Result<T> = try {\n    Result.success(block())\n} catch (cancellationException: CancellationException) {\n    throw cancellationException\n} catch (exception: Exception) {\n    Log.i(\n        \"suspendRunCatching\",\n        \"Failed to evaluate a suspendRunCatchingBlock. Returning failure Result\",\n        exception,\n    )\n    Result.failure(exception)\n}\n\n/**\n * Utility function for syncing a repository with the network.\n * [versionReader] Reads the current version of the model that needs to be synced\n * [changeListFetcher] Fetches the change list for the model\n * [versionUpdater] Updates the [ChangeListVersions] after a successful sync\n * [modelDeleter] Deletes models by consuming the ids of the models that have been deleted.\n * [modelUpdater] Updates models by consuming the ids of the models that have changed.\n *\n * Note that the blocks defined above are never run concurrently, and the [Synchronizer]\n * implementation must guarantee this.\n */\nsuspend fun Synchronizer.changeListSync(\n    versionReader: (ChangeListVersions) -> Int,\n    changeListFetcher: suspend (Int) -> List<NetworkChangeList>,\n    versionUpdater: ChangeListVersions.(Int) -> ChangeListVersions,\n    modelDeleter: suspend (List<String>) -> Unit,\n    modelUpdater: suspend (List<String>) -> Unit,\n) = suspendRunCatching {\n    // Fetch the change list since last sync (akin to a git fetch)\n    val currentVersion = versionReader(getChangeListVersions())\n    val changeList = changeListFetcher(currentVersion)\n    if (changeList.isEmpty()) return@suspendRunCatching true\n\n    val (deleted, updated) = changeList.partition(NetworkChangeList::isDelete)\n\n    // Delete models that have been deleted server-side\n    modelDeleter(deleted.map(NetworkChangeList::id))\n\n    // Using the change list, pull down and save the changes (akin to a git pull)\n    modelUpdater(updated.map(NetworkChangeList::id))\n\n    // Update the last synced version (akin to updating local git HEAD)\n    val latestVersion = changeList.last().changeListVersion\n    updateChangeListVersions {\n        versionUpdater(latestVersion)\n    }\n}.isSuccess\n"
  },
  {
    "path": "core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.di\n\nimport com.google.samples.apps.nowinandroid.core.data.repository.DefaultRecentSearchRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.DefaultSearchContentsRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstNewsRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstTopicsRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstUserDataRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository\nimport com.google.samples.apps.nowinandroid.core.data.util.ConnectivityManagerNetworkMonitor\nimport com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor\nimport com.google.samples.apps.nowinandroid.core.data.util.TimeZoneBroadcastMonitor\nimport com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor\nimport dagger.Binds\nimport dagger.Module\nimport dagger.hilt.InstallIn\nimport dagger.hilt.components.SingletonComponent\n\n@Module\n@InstallIn(SingletonComponent::class)\nabstract class DataModule {\n\n    @Binds\n    internal abstract fun bindsTopicRepository(\n        topicsRepository: OfflineFirstTopicsRepository,\n    ): TopicsRepository\n\n    @Binds\n    internal abstract fun bindsNewsResourceRepository(\n        newsRepository: OfflineFirstNewsRepository,\n    ): NewsRepository\n\n    @Binds\n    internal abstract fun bindsUserDataRepository(\n        userDataRepository: OfflineFirstUserDataRepository,\n    ): UserDataRepository\n\n    @Binds\n    internal abstract fun bindsRecentSearchRepository(\n        recentSearchRepository: DefaultRecentSearchRepository,\n    ): RecentSearchRepository\n\n    @Binds\n    internal abstract fun bindsSearchContentsRepository(\n        searchContentsRepository: DefaultSearchContentsRepository,\n    ): SearchContentsRepository\n\n    @Binds\n    internal abstract fun bindsNetworkMonitor(\n        networkMonitor: ConnectivityManagerNetworkMonitor,\n    ): NetworkMonitor\n\n    @Binds\n    internal abstract fun binds(impl: TimeZoneBroadcastMonitor): TimeZoneMonitor\n}\n"
  },
  {
    "path": "core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/di/UserNewsResourceRepositoryModule.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.di\n\nimport com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository\nimport dagger.Binds\nimport dagger.Module\nimport dagger.hilt.InstallIn\nimport dagger.hilt.components.SingletonComponent\n\n@Module\n@InstallIn(SingletonComponent::class)\ninternal interface UserNewsResourceRepositoryModule {\n    @Binds\n    fun bindsUserNewsResourceRepository(\n        userDataRepository: CompositeUserNewsResourceRepository,\n    ): UserNewsResourceRepository\n}\n"
  },
  {
    "path": "core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/model/NewsResource.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.model\n\nimport com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity\nimport com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef\nimport com.google.samples.apps.nowinandroid.core.database.model.TopicEntity\nimport com.google.samples.apps.nowinandroid.core.model.data.NewsResource\nimport com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource\nimport com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic\nimport com.google.samples.apps.nowinandroid.core.network.model.asExternalModel\n\nfun NetworkNewsResource.asEntity() = NewsResourceEntity(\n    id = id,\n    title = title,\n    content = content,\n    url = url,\n    headerImageUrl = headerImageUrl,\n    publishDate = publishDate,\n    type = type,\n)\n\n/**\n * A shell [TopicEntity] to fulfill the foreign key constraint when inserting\n * a [NewsResourceEntity] into the DB\n */\nfun NetworkNewsResource.topicEntityShells() =\n    topics.map { topicId ->\n        TopicEntity(\n            id = topicId,\n            name = \"\",\n            url = \"\",\n            imageUrl = \"\",\n            shortDescription = \"\",\n            longDescription = \"\",\n        )\n    }\n\nfun NetworkNewsResource.topicCrossReferences(): List<NewsResourceTopicCrossRef> =\n    topics.map { topicId ->\n        NewsResourceTopicCrossRef(\n            newsResourceId = id,\n            topicId = topicId,\n        )\n    }\n\nfun NetworkNewsResource.asExternalModel(topics: List<NetworkTopic>) =\n    NewsResource(\n        id = id,\n        title = title,\n        content = content,\n        url = url,\n        headerImageUrl = headerImageUrl,\n        publishDate = publishDate,\n        type = type,\n        topics = topics\n            .filter { networkTopic -> this.topics.contains(networkTopic.id) }\n            .map(NetworkTopic::asExternalModel),\n    )\n"
  },
  {
    "path": "core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/model/RecentSearchQuery.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.model\n\nimport com.google.samples.apps.nowinandroid.core.database.model.RecentSearchQueryEntity\nimport kotlinx.datetime.Clock\nimport kotlinx.datetime.Instant\n\ndata class RecentSearchQuery(\n    val query: String,\n    val queriedDate: Instant = Clock.System.now(),\n)\n\nfun RecentSearchQueryEntity.asExternalModel() = RecentSearchQuery(\n    query = query,\n    queriedDate = queriedDate,\n)\n"
  },
  {
    "path": "core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/model/Topic.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.model\n\nimport com.google.samples.apps.nowinandroid.core.database.model.TopicEntity\nimport com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic\n\nfun NetworkTopic.asEntity() = TopicEntity(\n    id = id,\n    name = name,\n    shortDescription = shortDescription,\n    longDescription = longDescription,\n    url = url,\n    imageUrl = imageUrl,\n)\n"
  },
  {
    "path": "core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/AnalyticsExtensions.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.repository\n\nimport com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent\nimport com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent.Param\nimport com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper\n\ninternal fun AnalyticsHelper.logNewsResourceBookmarkToggled(newsResourceId: String, isBookmarked: Boolean) {\n    val eventType = if (isBookmarked) \"news_resource_saved\" else \"news_resource_unsaved\"\n    val paramKey = if (isBookmarked) \"saved_news_resource_id\" else \"unsaved_news_resource_id\"\n    logEvent(\n        AnalyticsEvent(\n            type = eventType,\n            extras = listOf(\n                Param(key = paramKey, value = newsResourceId),\n            ),\n        ),\n    )\n}\n\ninternal fun AnalyticsHelper.logTopicFollowToggled(followedTopicId: String, isFollowed: Boolean) {\n    val eventType = if (isFollowed) \"topic_followed\" else \"topic_unfollowed\"\n    val paramKey = if (isFollowed) \"followed_topic_id\" else \"unfollowed_topic_id\"\n    logEvent(\n        AnalyticsEvent(\n            type = eventType,\n            extras = listOf(\n                Param(key = paramKey, value = followedTopicId),\n            ),\n        ),\n    )\n}\n\ninternal fun AnalyticsHelper.logThemeChanged(themeName: String) =\n    logEvent(\n        AnalyticsEvent(\n            type = \"theme_changed\",\n            extras = listOf(\n                Param(key = \"theme_name\", value = themeName),\n            ),\n        ),\n    )\n\ninternal fun AnalyticsHelper.logDarkThemeConfigChanged(darkThemeConfigName: String) =\n    logEvent(\n        AnalyticsEvent(\n            type = \"dark_theme_config_changed\",\n            extras = listOf(\n                Param(key = \"dark_theme_config\", value = darkThemeConfigName),\n            ),\n        ),\n    )\n\ninternal fun AnalyticsHelper.logDynamicColorPreferenceChanged(useDynamicColor: Boolean) =\n    logEvent(\n        AnalyticsEvent(\n            type = \"dynamic_color_preference_changed\",\n            extras = listOf(\n                Param(key = \"dynamic_color_preference\", value = useDynamicColor.toString()),\n            ),\n        ),\n    )\n\ninternal fun AnalyticsHelper.logOnboardingStateChanged(shouldHideOnboarding: Boolean) {\n    val eventType = if (shouldHideOnboarding) \"onboarding_complete\" else \"onboarding_reset\"\n    logEvent(\n        AnalyticsEvent(type = eventType),\n    )\n}\n"
  },
  {
    "path": "core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/CompositeUserNewsResourceRepository.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.repository\n\nimport com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource\nimport com.google.samples.apps.nowinandroid.core.model.data.mapToUserNewsResources\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.combine\nimport kotlinx.coroutines.flow.distinctUntilChanged\nimport kotlinx.coroutines.flow.flatMapLatest\nimport kotlinx.coroutines.flow.flowOf\nimport kotlinx.coroutines.flow.map\nimport javax.inject.Inject\n\n/**\n * Implements a [UserNewsResourceRepository] by combining a [NewsRepository] with a\n * [UserDataRepository].\n */\nclass CompositeUserNewsResourceRepository @Inject constructor(\n    val newsRepository: NewsRepository,\n    val userDataRepository: UserDataRepository,\n) : UserNewsResourceRepository {\n\n    /**\n     * Returns available news resources (joined with user data) matching the given query.\n     */\n    override fun observeAll(\n        query: NewsResourceQuery,\n    ): Flow<List<UserNewsResource>> =\n        newsRepository.getNewsResources(query)\n            .combine(userDataRepository.userData) { newsResources, userData ->\n                newsResources.mapToUserNewsResources(userData)\n            }\n\n    /**\n     * Returns available news resources (joined with user data) for the followed topics.\n     */\n    override fun observeAllForFollowedTopics(): Flow<List<UserNewsResource>> =\n        userDataRepository.userData.map { it.followedTopics }.distinctUntilChanged()\n            .flatMapLatest { followedTopics ->\n                when {\n                    followedTopics.isEmpty() -> flowOf(emptyList())\n                    else -> observeAll(NewsResourceQuery(filterTopicIds = followedTopics))\n                }\n            }\n\n    override fun observeAllBookmarked(): Flow<List<UserNewsResource>> =\n        userDataRepository.userData.map { it.bookmarkedNewsResources }.distinctUntilChanged()\n            .flatMapLatest { bookmarkedNewsResources ->\n                when {\n                    bookmarkedNewsResources.isEmpty() -> flowOf(emptyList())\n                    else -> observeAll(NewsResourceQuery(filterNewsIds = bookmarkedNewsResources))\n                }\n            }\n}\n"
  },
  {
    "path": "core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/DefaultRecentSearchRepository.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.repository\n\nimport com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery\nimport com.google.samples.apps.nowinandroid.core.data.model.asExternalModel\nimport com.google.samples.apps.nowinandroid.core.database.dao.RecentSearchQueryDao\nimport com.google.samples.apps.nowinandroid.core.database.model.RecentSearchQueryEntity\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.map\nimport kotlinx.datetime.Clock\nimport javax.inject.Inject\n\ninternal class DefaultRecentSearchRepository @Inject constructor(\n    private val recentSearchQueryDao: RecentSearchQueryDao,\n) : RecentSearchRepository {\n    override suspend fun insertOrReplaceRecentSearch(searchQuery: String) {\n        recentSearchQueryDao.insertOrReplaceRecentSearchQuery(\n            RecentSearchQueryEntity(\n                query = searchQuery,\n                queriedDate = Clock.System.now(),\n            ),\n        )\n    }\n\n    override fun getRecentSearchQueries(limit: Int): Flow<List<RecentSearchQuery>> =\n        recentSearchQueryDao.getRecentSearchQueryEntities(limit).map { searchQueries ->\n            searchQueries.map { it.asExternalModel() }\n        }\n\n    override suspend fun clearRecentSearches() = recentSearchQueryDao.clearRecentSearchQueries()\n}\n"
  },
  {
    "path": "core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/DefaultSearchContentsRepository.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.repository\n\nimport com.google.samples.apps.nowinandroid.core.common.network.Dispatcher\nimport com.google.samples.apps.nowinandroid.core.common.network.NiaDispatchers.IO\nimport com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao\nimport com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceFtsDao\nimport com.google.samples.apps.nowinandroid.core.database.dao.TopicDao\nimport com.google.samples.apps.nowinandroid.core.database.dao.TopicFtsDao\nimport com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource\nimport com.google.samples.apps.nowinandroid.core.database.model.asExternalModel\nimport com.google.samples.apps.nowinandroid.core.database.model.asFtsEntity\nimport com.google.samples.apps.nowinandroid.core.model.data.SearchResult\nimport kotlinx.coroutines.CoroutineDispatcher\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.combine\nimport kotlinx.coroutines.flow.distinctUntilChanged\nimport kotlinx.coroutines.flow.first\nimport kotlinx.coroutines.flow.flatMapLatest\nimport kotlinx.coroutines.flow.mapLatest\nimport kotlinx.coroutines.withContext\nimport javax.inject.Inject\n\ninternal class DefaultSearchContentsRepository @Inject constructor(\n    private val newsResourceDao: NewsResourceDao,\n    private val newsResourceFtsDao: NewsResourceFtsDao,\n    private val topicDao: TopicDao,\n    private val topicFtsDao: TopicFtsDao,\n    @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,\n) : SearchContentsRepository {\n\n    override suspend fun populateFtsData() {\n        withContext(ioDispatcher) {\n            newsResourceFtsDao.insertAll(\n                newsResourceDao.getNewsResources(\n                    useFilterTopicIds = false,\n                    useFilterNewsIds = false,\n                )\n                    .first()\n                    .map(PopulatedNewsResource::asFtsEntity),\n            )\n            topicFtsDao.insertAll(topicDao.getOneOffTopicEntities().map { it.asFtsEntity() })\n        }\n    }\n\n    override fun searchContents(searchQuery: String): Flow<SearchResult> {\n        // Surround the query by asterisks to match the query when it's in the middle of\n        // a word\n        val newsResourceIds = newsResourceFtsDao.searchAllNewsResources(\"*$searchQuery*\")\n        val topicIds = topicFtsDao.searchAllTopics(\"*$searchQuery*\")\n\n        val newsResourcesFlow = newsResourceIds\n            .mapLatest { it.toSet() }\n            .distinctUntilChanged()\n            .flatMapLatest {\n                newsResourceDao.getNewsResources(useFilterNewsIds = true, filterNewsIds = it)\n            }\n        val topicsFlow = topicIds\n            .mapLatest { it.toSet() }\n            .distinctUntilChanged()\n            .flatMapLatest(topicDao::getTopicEntities)\n        return combine(newsResourcesFlow, topicsFlow) { newsResources, topics ->\n            SearchResult(\n                topics = topics.map { it.asExternalModel() },\n                newsResources = newsResources.map { it.asExternalModel() },\n            )\n        }\n    }\n\n    override fun getSearchContentsCount(): Flow<Int> =\n        combine(\n            newsResourceFtsDao.getCount(),\n            topicFtsDao.getCount(),\n        ) { newsResourceCount, topicsCount ->\n            newsResourceCount + topicsCount\n        }\n}\n"
  },
  {
    "path": "core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/NewsRepository.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.repository\n\nimport com.google.samples.apps.nowinandroid.core.data.Syncable\nimport com.google.samples.apps.nowinandroid.core.model.data.NewsResource\nimport kotlinx.coroutines.flow.Flow\n\n/**\n * Encapsulation class for query parameters for [NewsResource]\n */\ndata class NewsResourceQuery(\n    /**\n     * Topic ids to filter for. Null means any topic id will match.\n     */\n    val filterTopicIds: Set<String>? = null,\n    /**\n     * News ids to filter for. Null means any news id will match.\n     */\n    val filterNewsIds: Set<String>? = null,\n)\n\n/**\n * Data layer implementation for [NewsResource]\n */\ninterface NewsRepository : Syncable {\n    /**\n     * Returns available news resources that match the specified [query].\n     */\n    fun getNewsResources(\n        query: NewsResourceQuery = NewsResourceQuery(\n            filterTopicIds = null,\n            filterNewsIds = null,\n        ),\n    ): Flow<List<NewsResource>>\n}\n"
  },
  {
    "path": "core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.repository\n\nimport com.google.samples.apps.nowinandroid.core.data.Synchronizer\nimport com.google.samples.apps.nowinandroid.core.data.changeListSync\nimport com.google.samples.apps.nowinandroid.core.data.model.asEntity\nimport com.google.samples.apps.nowinandroid.core.data.model.topicCrossReferences\nimport com.google.samples.apps.nowinandroid.core.data.model.topicEntityShells\nimport com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao\nimport com.google.samples.apps.nowinandroid.core.database.dao.TopicDao\nimport com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource\nimport com.google.samples.apps.nowinandroid.core.database.model.TopicEntity\nimport com.google.samples.apps.nowinandroid.core.database.model.asExternalModel\nimport com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions\nimport com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource\nimport com.google.samples.apps.nowinandroid.core.model.data.NewsResource\nimport com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource\nimport com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource\nimport com.google.samples.apps.nowinandroid.core.notifications.Notifier\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.first\nimport kotlinx.coroutines.flow.map\nimport javax.inject.Inject\n\n// Heuristic value to optimize for serialization and deserialization cost on client and server\n// for each news resource batch.\nprivate const val SYNC_BATCH_SIZE = 40\n\n/**\n * Disk storage backed implementation of the [NewsRepository].\n * Reads are exclusively from local storage to support offline access.\n */\ninternal class OfflineFirstNewsRepository @Inject constructor(\n    private val niaPreferencesDataSource: NiaPreferencesDataSource,\n    private val newsResourceDao: NewsResourceDao,\n    private val topicDao: TopicDao,\n    private val network: NiaNetworkDataSource,\n    private val notifier: Notifier,\n) : NewsRepository {\n\n    override fun getNewsResources(\n        query: NewsResourceQuery,\n    ): Flow<List<NewsResource>> = newsResourceDao.getNewsResources(\n        useFilterTopicIds = query.filterTopicIds != null,\n        filterTopicIds = query.filterTopicIds ?: emptySet(),\n        useFilterNewsIds = query.filterNewsIds != null,\n        filterNewsIds = query.filterNewsIds ?: emptySet(),\n    )\n        .map { it.map(PopulatedNewsResource::asExternalModel) }\n\n    override suspend fun syncWith(synchronizer: Synchronizer): Boolean {\n        var isFirstSync = false\n        return synchronizer.changeListSync(\n            versionReader = ChangeListVersions::newsResourceVersion,\n            changeListFetcher = { currentVersion ->\n                isFirstSync = currentVersion <= 0\n                network.getNewsResourceChangeList(after = currentVersion)\n            },\n            versionUpdater = { latestVersion ->\n                copy(newsResourceVersion = latestVersion)\n            },\n            modelDeleter = newsResourceDao::deleteNewsResources,\n            modelUpdater = { changedIds ->\n                val userData = niaPreferencesDataSource.userData.first()\n                val hasOnboarded = userData.shouldHideOnboarding\n                val followedTopicIds = userData.followedTopics\n\n                val existingNewsResourceIdsThatHaveChanged = when {\n                    hasOnboarded -> newsResourceDao.getNewsResourceIds(\n                        useFilterTopicIds = true,\n                        filterTopicIds = followedTopicIds,\n                        useFilterNewsIds = true,\n                        filterNewsIds = changedIds.toSet(),\n                    )\n                        .first()\n                        .toSet()\n                    // No need to retrieve anything if notifications won't be sent\n                    else -> emptySet()\n                }\n\n                if (isFirstSync) {\n                    // When we first retrieve news, mark everything viewed, so that we aren't\n                    // overwhelmed with all historical news.\n                    niaPreferencesDataSource.setNewsResourcesViewed(changedIds, true)\n                }\n\n                // Obtain the news resources which have changed from the network and upsert them locally\n                changedIds.chunked(SYNC_BATCH_SIZE).forEach { chunkedIds ->\n                    val networkNewsResources = network.getNewsResources(ids = chunkedIds)\n\n                    // Order of invocation matters to satisfy id and foreign key constraints!\n\n                    topicDao.insertOrIgnoreTopics(\n                        topicEntities = networkNewsResources\n                            .map(NetworkNewsResource::topicEntityShells)\n                            .flatten()\n                            .distinctBy(TopicEntity::id),\n                    )\n                    newsResourceDao.upsertNewsResources(\n                        newsResourceEntities = networkNewsResources.map(\n                            NetworkNewsResource::asEntity,\n                        ),\n                    )\n                    newsResourceDao.insertOrIgnoreTopicCrossRefEntities(\n                        newsResourceTopicCrossReferences = networkNewsResources\n                            .map(NetworkNewsResource::topicCrossReferences)\n                            .distinct()\n                            .flatten(),\n                    )\n                }\n\n                if (hasOnboarded) {\n                    val addedNewsResources = newsResourceDao.getNewsResources(\n                        useFilterTopicIds = true,\n                        filterTopicIds = followedTopicIds,\n                        useFilterNewsIds = true,\n                        filterNewsIds = changedIds.toSet() - existingNewsResourceIdsThatHaveChanged,\n                    )\n                        .first()\n                        .map(PopulatedNewsResource::asExternalModel)\n\n                    if (addedNewsResources.isNotEmpty()) {\n                        notifier.postNewsNotifications(\n                            newsResources = addedNewsResources,\n                        )\n                    }\n                }\n            },\n        )\n    }\n}\n"
  },
  {
    "path": "core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstTopicsRepository.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.repository\n\nimport com.google.samples.apps.nowinandroid.core.data.Synchronizer\nimport com.google.samples.apps.nowinandroid.core.data.changeListSync\nimport com.google.samples.apps.nowinandroid.core.data.model.asEntity\nimport com.google.samples.apps.nowinandroid.core.database.dao.TopicDao\nimport com.google.samples.apps.nowinandroid.core.database.model.TopicEntity\nimport com.google.samples.apps.nowinandroid.core.database.model.asExternalModel\nimport com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions\nimport com.google.samples.apps.nowinandroid.core.model.data.Topic\nimport com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource\nimport com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.map\nimport javax.inject.Inject\n\n/**\n * Disk storage backed implementation of the [TopicsRepository].\n * Reads are exclusively from local storage to support offline access.\n */\ninternal class OfflineFirstTopicsRepository @Inject constructor(\n    private val topicDao: TopicDao,\n    private val network: NiaNetworkDataSource,\n) : TopicsRepository {\n\n    override fun getTopics(): Flow<List<Topic>> =\n        topicDao.getTopicEntities()\n            .map { it.map(TopicEntity::asExternalModel) }\n\n    override fun getTopic(id: String): Flow<Topic> =\n        topicDao.getTopicEntity(id).map { it.asExternalModel() }\n\n    override suspend fun syncWith(synchronizer: Synchronizer): Boolean =\n        synchronizer.changeListSync(\n            versionReader = ChangeListVersions::topicVersion,\n            changeListFetcher = { currentVersion ->\n                network.getTopicChangeList(after = currentVersion)\n            },\n            versionUpdater = { latestVersion ->\n                copy(topicVersion = latestVersion)\n            },\n            modelDeleter = topicDao::deleteTopics,\n            modelUpdater = { changedIds ->\n                val networkTopics = network.getTopics(ids = changedIds)\n                topicDao.upsertTopics(\n                    entities = networkTopics.map(NetworkTopic::asEntity),\n                )\n            },\n        )\n}\n"
  },
  {
    "path": "core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.repository\n\nimport androidx.annotation.VisibleForTesting\nimport com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper\nimport com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource\nimport com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig\nimport com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand\nimport com.google.samples.apps.nowinandroid.core.model.data.UserData\nimport kotlinx.coroutines.flow.Flow\nimport javax.inject.Inject\n\ninternal class OfflineFirstUserDataRepository @Inject constructor(\n    private val niaPreferencesDataSource: NiaPreferencesDataSource,\n    private val analyticsHelper: AnalyticsHelper,\n) : UserDataRepository {\n\n    override val userData: Flow<UserData> =\n        niaPreferencesDataSource.userData\n\n    @VisibleForTesting\n    override suspend fun setFollowedTopicIds(followedTopicIds: Set<String>) =\n        niaPreferencesDataSource.setFollowedTopicIds(followedTopicIds)\n\n    override suspend fun setTopicIdFollowed(followedTopicId: String, followed: Boolean) {\n        niaPreferencesDataSource.setTopicIdFollowed(followedTopicId, followed)\n        analyticsHelper.logTopicFollowToggled(followedTopicId, followed)\n    }\n\n    override suspend fun setNewsResourceBookmarked(newsResourceId: String, bookmarked: Boolean) {\n        niaPreferencesDataSource.setNewsResourceBookmarked(newsResourceId, bookmarked)\n        analyticsHelper.logNewsResourceBookmarkToggled(\n            newsResourceId = newsResourceId,\n            isBookmarked = bookmarked,\n        )\n    }\n\n    override suspend fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) =\n        niaPreferencesDataSource.setNewsResourceViewed(newsResourceId, viewed)\n\n    override suspend fun setThemeBrand(themeBrand: ThemeBrand) {\n        niaPreferencesDataSource.setThemeBrand(themeBrand)\n        analyticsHelper.logThemeChanged(themeBrand.name)\n    }\n\n    override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) {\n        niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig)\n        analyticsHelper.logDarkThemeConfigChanged(darkThemeConfig.name)\n    }\n\n    override suspend fun setDynamicColorPreference(useDynamicColor: Boolean) {\n        niaPreferencesDataSource.setDynamicColorPreference(useDynamicColor)\n        analyticsHelper.logDynamicColorPreferenceChanged(useDynamicColor)\n    }\n\n    override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) {\n        niaPreferencesDataSource.setShouldHideOnboarding(shouldHideOnboarding)\n        analyticsHelper.logOnboardingStateChanged(shouldHideOnboarding)\n    }\n}\n"
  },
  {
    "path": "core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/RecentSearchRepository.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.repository\n\nimport com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery\nimport kotlinx.coroutines.flow.Flow\n\n/**\n * Data layer interface for the recent searches.\n */\ninterface RecentSearchRepository {\n\n    /**\n     * Get the recent search queries up to the number of queries specified as [limit].\n     */\n    fun getRecentSearchQueries(limit: Int): Flow<List<RecentSearchQuery>>\n\n    /**\n     * Insert or replace the [searchQuery] as part of the recent searches.\n     */\n    suspend fun insertOrReplaceRecentSearch(searchQuery: String)\n\n    /**\n     * Clear the recent searches.\n     */\n    suspend fun clearRecentSearches()\n}\n"
  },
  {
    "path": "core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/SearchContentsRepository.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.repository\n\nimport com.google.samples.apps.nowinandroid.core.model.data.SearchResult\nimport kotlinx.coroutines.flow.Flow\n\n/**\n * Data layer interface for the search feature.\n */\ninterface SearchContentsRepository {\n\n    /**\n     * Populate the fts tables for the search contents.\n     */\n    suspend fun populateFtsData()\n\n    /**\n     * Query the contents matched with the [searchQuery] and returns it as a [Flow] of [SearchResult]\n     */\n    fun searchContents(searchQuery: String): Flow<SearchResult>\n\n    fun getSearchContentsCount(): Flow<Int>\n}\n"
  },
  {
    "path": "core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/TopicsRepository.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.repository\n\nimport com.google.samples.apps.nowinandroid.core.data.Syncable\nimport com.google.samples.apps.nowinandroid.core.model.data.Topic\nimport kotlinx.coroutines.flow.Flow\n\ninterface TopicsRepository : Syncable {\n    /**\n     * Gets the available topics as a stream\n     */\n    fun getTopics(): Flow<List<Topic>>\n\n    /**\n     * Gets data for a specific topic\n     */\n    fun getTopic(id: String): Flow<Topic>\n}\n"
  },
  {
    "path": "core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/UserDataRepository.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.repository\n\nimport com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig\nimport com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand\nimport com.google.samples.apps.nowinandroid.core.model.data.UserData\nimport kotlinx.coroutines.flow.Flow\n\ninterface UserDataRepository {\n\n    /**\n     * Stream of [UserData]\n     */\n    val userData: Flow<UserData>\n\n    /**\n     * Sets the user's currently followed topics\n     */\n    suspend fun setFollowedTopicIds(followedTopicIds: Set<String>)\n\n    /**\n     * Sets the user's newly followed/unfollowed topic\n     */\n    suspend fun setTopicIdFollowed(followedTopicId: String, followed: Boolean)\n\n    /**\n     * Updates the bookmarked status for a news resource\n     */\n    suspend fun setNewsResourceBookmarked(newsResourceId: String, bookmarked: Boolean)\n\n    /**\n     * Updates the viewed status for a news resource\n     */\n    suspend fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean)\n\n    /**\n     * Sets the desired theme brand.\n     */\n    suspend fun setThemeBrand(themeBrand: ThemeBrand)\n\n    /**\n     * Sets the desired dark theme config.\n     */\n    suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig)\n\n    /**\n     * Sets the preferred dynamic color config.\n     */\n    suspend fun setDynamicColorPreference(useDynamicColor: Boolean)\n\n    /**\n     * Sets whether the user has completed the onboarding process.\n     */\n    suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean)\n}\n"
  },
  {
    "path": "core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/UserNewsResourceRepository.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.repository\n\nimport com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource\nimport kotlinx.coroutines.flow.Flow\n\n/**\n * Data layer implementation for [UserNewsResource]\n */\ninterface UserNewsResourceRepository {\n    /**\n     * Returns available news resources as a stream.\n     */\n    fun observeAll(\n        query: NewsResourceQuery = NewsResourceQuery(\n            filterTopicIds = null,\n            filterNewsIds = null,\n        ),\n    ): Flow<List<UserNewsResource>>\n\n    /**\n     * Returns available news resources for the user's followed topics as a stream.\n     */\n    fun observeAllForFollowedTopics(): Flow<List<UserNewsResource>>\n\n    /**\n     * Returns the user's bookmarked news resources as a stream.\n     */\n    fun observeAllBookmarked(): Flow<List<UserNewsResource>>\n}\n"
  },
  {
    "path": "core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/util/ConnectivityManagerNetworkMonitor.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.util\n\nimport android.content.Context\nimport android.net.ConnectivityManager\nimport android.net.ConnectivityManager.NetworkCallback\nimport android.net.Network\nimport android.net.NetworkCapabilities\nimport android.net.NetworkRequest\nimport android.net.NetworkRequest.Builder\nimport androidx.core.content.getSystemService\nimport androidx.tracing.trace\nimport com.google.samples.apps.nowinandroid.core.common.network.Dispatcher\nimport com.google.samples.apps.nowinandroid.core.common.network.NiaDispatchers.IO\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport kotlinx.coroutines.CoroutineDispatcher\nimport kotlinx.coroutines.channels.awaitClose\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.callbackFlow\nimport kotlinx.coroutines.flow.conflate\nimport kotlinx.coroutines.flow.flowOn\nimport javax.inject.Inject\n\ninternal class ConnectivityManagerNetworkMonitor @Inject constructor(\n    @ApplicationContext private val context: Context,\n    @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,\n) : NetworkMonitor {\n    override val isOnline: Flow<Boolean> = callbackFlow {\n        trace(\"NetworkMonitor.callbackFlow\") {\n            val connectivityManager = context.getSystemService<ConnectivityManager>()\n            if (connectivityManager == null) {\n                channel.trySend(false)\n                channel.close()\n                return@callbackFlow\n            }\n\n            /**\n             * The callback's methods are invoked on changes to *any* network matching the [NetworkRequest],\n             * not just the active network. So we can simply track the presence (or absence) of such [Network].\n             */\n            val callback = object : NetworkCallback() {\n\n                private val networks = mutableSetOf<Network>()\n\n                override fun onAvailable(network: Network) {\n                    networks += network\n                    channel.trySend(true)\n                }\n\n                override fun onLost(network: Network) {\n                    networks -= network\n                    channel.trySend(networks.isNotEmpty())\n                }\n            }\n\n            trace(\"NetworkMonitor.registerNetworkCallback\") {\n                val request = Builder()\n                    .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)\n                    .build()\n                connectivityManager.registerNetworkCallback(request, callback)\n            }\n\n            /**\n             * Sends the latest connectivity status to the underlying channel.\n             */\n            channel.trySend(connectivityManager.isCurrentlyConnected())\n\n            awaitClose {\n                connectivityManager.unregisterNetworkCallback(callback)\n            }\n        }\n    }\n        .flowOn(ioDispatcher)\n        .conflate()\n\n    private fun ConnectivityManager.isCurrentlyConnected(): Boolean {\n        val networkCapabilities = getNetworkCapabilities(activeNetwork) ?: return false\n        return networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)\n    }\n}\n"
  },
  {
    "path": "core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/util/NetworkMonitor.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.util\n\nimport kotlinx.coroutines.flow.Flow\n\n/**\n * Utility for reporting app connectivity status\n */\ninterface NetworkMonitor {\n    val isOnline: Flow<Boolean>\n}\n"
  },
  {
    "path": "core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/util/SyncManager.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.util\n\nimport kotlinx.coroutines.flow.Flow\n\n/**\n * Reports on if synchronization is in progress\n */\ninterface SyncManager {\n    val isSyncing: Flow<Boolean>\n    fun requestSync()\n}\n"
  },
  {
    "path": "core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/util/TimeZoneMonitor.kt",
    "content": "/*\n * Copyright 2024 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.util\n\nimport android.content.BroadcastReceiver\nimport android.content.Context\nimport android.content.Intent\nimport android.content.IntentFilter\nimport android.os.Build.VERSION\nimport android.os.Build.VERSION_CODES\nimport androidx.tracing.trace\nimport com.google.samples.apps.nowinandroid.core.common.network.Dispatcher\nimport com.google.samples.apps.nowinandroid.core.common.network.NiaDispatchers.IO\nimport com.google.samples.apps.nowinandroid.core.common.network.di.ApplicationScope\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport kotlinx.coroutines.CoroutineDispatcher\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.channels.awaitClose\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.SharedFlow\nimport kotlinx.coroutines.flow.SharingStarted\nimport kotlinx.coroutines.flow.callbackFlow\nimport kotlinx.coroutines.flow.conflate\nimport kotlinx.coroutines.flow.distinctUntilChanged\nimport kotlinx.coroutines.flow.flowOn\nimport kotlinx.coroutines.flow.shareIn\nimport kotlinx.datetime.TimeZone\nimport kotlinx.datetime.toKotlinTimeZone\nimport java.time.ZoneId\nimport javax.inject.Inject\nimport javax.inject.Singleton\n\n/**\n * Utility for reporting current timezone the device has set.\n * It always emits at least once with default setting and then for each TZ change.\n */\ninterface TimeZoneMonitor {\n    val currentTimeZone: Flow<TimeZone>\n}\n\n@Singleton\ninternal class TimeZoneBroadcastMonitor @Inject constructor(\n    @ApplicationContext private val context: Context,\n    @ApplicationScope appScope: CoroutineScope,\n    @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,\n) : TimeZoneMonitor {\n\n    override val currentTimeZone: SharedFlow<TimeZone> =\n        callbackFlow {\n            // Send the default time zone first.\n            trySend(TimeZone.currentSystemDefault())\n\n            // Registers BroadcastReceiver for the TimeZone changes\n            val receiver = object : BroadcastReceiver() {\n                override fun onReceive(context: Context, intent: Intent) {\n                    if (intent.action != Intent.ACTION_TIMEZONE_CHANGED) return\n\n                    val zoneIdFromIntent = if (VERSION.SDK_INT < VERSION_CODES.R) {\n                        null\n                    } else {\n                        // Starting Android R we also get the new TimeZone.\n                        intent.getStringExtra(Intent.EXTRA_TIMEZONE)?.let { timeZoneId ->\n                            // We need to convert it from java.util.Timezone to java.time.ZoneId\n                            val zoneId = ZoneId.of(timeZoneId, ZoneId.SHORT_IDS)\n                            // Convert to kotlinx.datetime.TimeZone\n                            zoneId.toKotlinTimeZone()\n                        }\n                    }\n\n                    // If there isn't a zoneId in the intent, fallback to the systemDefault, which should also reflect the change\n                    trySend(zoneIdFromIntent ?: TimeZone.currentSystemDefault())\n                }\n            }\n\n            trace(\"TimeZoneBroadcastReceiver.register\") {\n                context.registerReceiver(receiver, IntentFilter(Intent.ACTION_TIMEZONE_CHANGED))\n            }\n\n            // Send here again, because registering the Broadcast Receiver can take up to several milliseconds.\n            // This way, we can reduce the likelihood that a TZ change wouldn't be caught with the Broadcast Receiver.\n            trySend(TimeZone.currentSystemDefault())\n\n            awaitClose {\n                context.unregisterReceiver(receiver)\n            }\n        }\n            // We use to prevent multiple emissions of the same type, because we use trySend multiple times.\n            .distinctUntilChanged()\n            .conflate()\n            .flowOn(ioDispatcher)\n            // Sharing the callback to prevent multiple BroadcastReceivers being registered\n            .shareIn(appScope, SharingStarted.WhileSubscribed(5_000), 1)\n}\n"
  },
  {
    "path": "core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/CompositeUserNewsResourceRepositoryTest.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data\n\nimport com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery\nimport com.google.samples.apps.nowinandroid.core.model.data.NewsResource\nimport com.google.samples.apps.nowinandroid.core.model.data.Topic\nimport com.google.samples.apps.nowinandroid.core.model.data.mapToUserNewsResources\nimport com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository\nimport com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository\nimport com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData\nimport kotlinx.coroutines.flow.first\nimport kotlinx.coroutines.test.runTest\nimport kotlinx.datetime.Instant\nimport org.junit.Test\nimport kotlin.test.assertEquals\n\nclass CompositeUserNewsResourceRepositoryTest {\n\n    private val newsRepository = TestNewsRepository()\n    private val userDataRepository = TestUserDataRepository()\n\n    private val userNewsResourceRepository = CompositeUserNewsResourceRepository(\n        newsRepository = newsRepository,\n        userDataRepository = userDataRepository,\n    )\n\n    @Test\n    fun whenNoFilters_allNewsResourcesAreReturned() = runTest {\n        // Obtain the user news resources flow.\n        val userNewsResources = userNewsResourceRepository.observeAll()\n\n        // Send some news resources and user data into the data repositories.\n        newsRepository.sendNewsResources(sampleNewsResources)\n\n        // Construct the test user data with bookmarks and followed topics.\n        val userData = emptyUserData.copy(\n            bookmarkedNewsResources = setOf(sampleNewsResources[0].id, sampleNewsResources[2].id),\n            followedTopics = setOf(sampleTopic1.id),\n        )\n\n        userDataRepository.setUserData(userData)\n\n        // Check that the correct news resources are returned with their bookmarked state.\n        assertEquals(\n            sampleNewsResources.mapToUserNewsResources(userData),\n            userNewsResources.first(),\n        )\n    }\n\n    @Test\n    fun whenFilteredByTopicId_matchingNewsResourcesAreReturned() = runTest {\n        // Obtain a stream of user news resources for the given topic id.\n        val userNewsResources =\n            userNewsResourceRepository.observeAll(\n                NewsResourceQuery(\n                    filterTopicIds = setOf(\n                        sampleTopic1.id,\n                    ),\n                ),\n            )\n\n        // Send test data into the repositories.\n        newsRepository.sendNewsResources(sampleNewsResources)\n        userDataRepository.setUserData(emptyUserData)\n\n        // Check that only news resources with the given topic id are returned.\n        assertEquals(\n            sampleNewsResources\n                .filter { sampleTopic1 in it.topics }\n                .mapToUserNewsResources(emptyUserData),\n            userNewsResources.first(),\n        )\n    }\n\n    @Test\n    fun whenFilteredByFollowedTopics_matchingNewsResourcesAreReturned() = runTest {\n        // Obtain a stream of user news resources for the given topic id.\n        val userNewsResources =\n            userNewsResourceRepository.observeAllForFollowedTopics()\n\n        // Send test data into the repositories.\n        val userData = emptyUserData.copy(\n            followedTopics = setOf(sampleTopic1.id),\n        )\n        newsRepository.sendNewsResources(sampleNewsResources)\n        userDataRepository.setUserData(userData)\n\n        // Check that only news resources with the given topic id are returned.\n        assertEquals(\n            sampleNewsResources\n                .filter { sampleTopic1 in it.topics }\n                .mapToUserNewsResources(userData),\n            userNewsResources.first(),\n        )\n    }\n\n    @Test\n    fun whenFilteredByBookmarkedResources_matchingNewsResourcesAreReturned() = runTest {\n        // Obtain the bookmarked user news resources flow.\n        val userNewsResources = userNewsResourceRepository.observeAllBookmarked()\n\n        // Send some news resources and user data into the data repositories.\n        newsRepository.sendNewsResources(sampleNewsResources)\n\n        // Construct the test user data with bookmarks and followed topics.\n        val userData = emptyUserData.copy(\n            bookmarkedNewsResources = setOf(sampleNewsResources[0].id, sampleNewsResources[2].id),\n            followedTopics = setOf(sampleTopic1.id),\n        )\n\n        userDataRepository.setUserData(userData)\n\n        // Check that the correct news resources are returned with their bookmarked state.\n        assertEquals(\n            listOf(sampleNewsResources[0], sampleNewsResources[2]).mapToUserNewsResources(userData),\n            userNewsResources.first(),\n        )\n    }\n}\n\nprivate val sampleTopic1 = Topic(\n    id = \"Topic1\",\n    name = \"Headlines\",\n    shortDescription = \"\",\n    longDescription = \"long description\",\n    url = \"URL\",\n    imageUrl = \"image URL\",\n)\n\nprivate val sampleTopic2 = Topic(\n    id = \"Topic2\",\n    name = \"UI\",\n    shortDescription = \"\",\n    longDescription = \"long description\",\n    url = \"URL\",\n    imageUrl = \"image URL\",\n)\n\nprivate val sampleNewsResources = listOf(\n    NewsResource(\n        id = \"1\",\n        title = \"Thanks for helping us reach 1M YouTube Subscribers\",\n        content = \"Thank you everyone for following the Now in Android series and everything the \" +\n            \"Android Developers YouTube channel has to offer. During the Android Developer \" +\n            \"Summit, our YouTube channel reached 1 million subscribers! Here’s a small video to \" +\n            \"thank you all.\",\n        url = \"https://youtu.be/-fJ6poHQrjM\",\n        headerImageUrl = \"https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg\",\n        publishDate = Instant.parse(\"2021-11-09T00:00:00.000Z\"),\n        type = \"Video 📺\",\n        topics = listOf(sampleTopic1),\n    ),\n    NewsResource(\n        id = \"2\",\n        title = \"Transformations and customisations in the Paging Library\",\n        content = \"A demonstration of different operations that can be performed with Paging. \" +\n            \"Transformations like inserting separators, when to create a new pager, and \" +\n            \"customisation options for consuming PagingData.\",\n        url = \"https://youtu.be/ZARz0pjm5YM\",\n        headerImageUrl = \"https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg\",\n        publishDate = Instant.parse(\"2021-11-01T00:00:00.000Z\"),\n        type = \"Video 📺\",\n        topics = listOf(sampleTopic1, sampleTopic2),\n    ),\n    NewsResource(\n        id = \"3\",\n        title = \"Community tip on Paging\",\n        content = \"Tips for using the Paging library from the developer community\",\n        url = \"https://youtu.be/r5JgIyS3t3s\",\n        headerImageUrl = \"https://i.ytimg.com/vi/r5JgIyS3t3s/maxresdefault.jpg\",\n        publishDate = Instant.parse(\"2021-11-08T00:00:00.000Z\"),\n        type = \"Video 📺\",\n        topics = listOf(sampleTopic2),\n    ),\n)\n"
  },
  {
    "path": "core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/UserNewsResourceTest.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data\n\nimport com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.FOLLOW_SYSTEM\nimport com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic\nimport com.google.samples.apps.nowinandroid.core.model.data.NewsResource\nimport com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.DEFAULT\nimport com.google.samples.apps.nowinandroid.core.model.data.Topic\nimport com.google.samples.apps.nowinandroid.core.model.data.UserData\nimport com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource\nimport kotlinx.datetime.Clock\nimport org.junit.Test\nimport kotlin.test.assertEquals\nimport kotlin.test.assertTrue\n\nclass UserNewsResourceTest {\n\n    /**\n     * Given: Some user data and news resources\n     * When: They are combined using `UserNewsResource.from`\n     * Then: The correct UserNewsResources are constructed\n     */\n    @Test\n    fun userNewsResourcesAreConstructedFromNewsResourcesAndUserData() {\n        val newsResource1 = NewsResource(\n            id = \"N1\",\n            title = \"Test news title\",\n            content = \"Test news content\",\n            url = \"Test URL\",\n            headerImageUrl = \"Test image URL\",\n            publishDate = Clock.System.now(),\n            type = \"Article 📚\",\n            topics = listOf(\n                Topic(\n                    id = \"T1\",\n                    name = \"Topic 1\",\n                    shortDescription = \"Topic 1 short description\",\n                    longDescription = \"Topic 1 long description\",\n                    url = \"Topic 1 URL\",\n                    imageUrl = \"Topic 1 image URL\",\n                ),\n                Topic(\n                    id = \"T2\",\n                    name = \"Topic 2\",\n                    shortDescription = \"Topic 2 short description\",\n                    longDescription = \"Topic 2 long description\",\n                    url = \"Topic 2 URL\",\n                    imageUrl = \"Topic 2 image URL\",\n                ),\n            ),\n        )\n\n        val userData = UserData(\n            bookmarkedNewsResources = setOf(\"N1\"),\n            viewedNewsResources = setOf(\"N1\"),\n            followedTopics = setOf(\"T1\"),\n            themeBrand = DEFAULT,\n            darkThemeConfig = FOLLOW_SYSTEM,\n            useDynamicColor = false,\n            shouldHideOnboarding = true,\n        )\n\n        val userNewsResource = UserNewsResource(newsResource1, userData)\n\n        // Check that the simple field mappings have been done correctly.\n        assertEquals(newsResource1.id, userNewsResource.id)\n        assertEquals(newsResource1.title, userNewsResource.title)\n        assertEquals(newsResource1.content, userNewsResource.content)\n        assertEquals(newsResource1.url, userNewsResource.url)\n        assertEquals(newsResource1.headerImageUrl, userNewsResource.headerImageUrl)\n        assertEquals(newsResource1.publishDate, userNewsResource.publishDate)\n\n        // Check that each Topic has been converted to a FollowedTopic correctly.\n        assertEquals(newsResource1.topics.size, userNewsResource.followableTopics.size)\n        for (topic in newsResource1.topics) {\n            // Construct the expected FollowableTopic.\n            val followableTopic = FollowableTopic(\n                topic = topic,\n                isFollowed = topic.id in userData.followedTopics,\n            )\n            assertTrue(userNewsResource.followableTopics.contains(followableTopic))\n        }\n\n        // Check that the saved flag is set correctly.\n        assertEquals(\n            newsResource1.id in userData.bookmarkedNewsResources,\n            userNewsResource.isSaved,\n        )\n    }\n}\n"
  },
  {
    "path": "core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/model/NetworkEntityTest.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.model\n\nimport com.google.samples.apps.nowinandroid.core.model.data.NewsResource\nimport com.google.samples.apps.nowinandroid.core.model.data.Topic\nimport com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource\nimport com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic\nimport com.google.samples.apps.nowinandroid.core.network.model.asExternalModel\nimport kotlinx.datetime.Instant\nimport org.junit.Test\nimport kotlin.test.assertEquals\n\nclass NetworkEntityTest {\n\n    @Test\n    fun networkTopicMapsToDatabaseModel() {\n        val networkModel = NetworkTopic(\n            id = \"0\",\n            name = \"Test\",\n            shortDescription = \"short description\",\n            longDescription = \"long description\",\n            url = \"URL\",\n            imageUrl = \"image URL\",\n        )\n        val entity = networkModel.asEntity()\n\n        assertEquals(\"0\", entity.id)\n        assertEquals(\"Test\", entity.name)\n        assertEquals(\"short description\", entity.shortDescription)\n        assertEquals(\"long description\", entity.longDescription)\n        assertEquals(\"URL\", entity.url)\n        assertEquals(\"image URL\", entity.imageUrl)\n    }\n\n    @Test\n    fun networkNewsResourceMapsToDatabaseModel() {\n        val networkModel =\n            NetworkNewsResource(\n                id = \"0\",\n                title = \"title\",\n                content = \"content\",\n                url = \"url\",\n                headerImageUrl = \"headerImageUrl\",\n                publishDate = Instant.fromEpochMilliseconds(1),\n                type = \"Article 📚\",\n            )\n        val entity = networkModel.asEntity()\n\n        assertEquals(\"0\", entity.id)\n        assertEquals(\"title\", entity.title)\n        assertEquals(\"content\", entity.content)\n        assertEquals(\"url\", entity.url)\n        assertEquals(\"headerImageUrl\", entity.headerImageUrl)\n        assertEquals(Instant.fromEpochMilliseconds(1), entity.publishDate)\n        assertEquals(\"Article 📚\", entity.type)\n    }\n\n    @Test\n    fun networkTopicMapsToExternalModel() {\n        val networkTopic = NetworkTopic(\n            id = \"0\",\n            name = \"Test\",\n            shortDescription = \"short description\",\n            longDescription = \"long description\",\n            url = \"URL\",\n            imageUrl = \"imageUrl\",\n        )\n\n        val expected = Topic(\n            id = \"0\",\n            name = \"Test\",\n            shortDescription = \"short description\",\n            longDescription = \"long description\",\n            url = \"URL\",\n            imageUrl = \"imageUrl\",\n        )\n\n        assertEquals(expected, networkTopic.asExternalModel())\n    }\n\n    @Test\n    fun networkNewsResourceMapsToExternalModel() {\n        val networkNewsResource = NetworkNewsResource(\n            id = \"0\",\n            title = \"title\",\n            content = \"content\",\n            url = \"url\",\n            headerImageUrl = \"headerImageUrl\",\n            publishDate = Instant.fromEpochMilliseconds(1),\n            type = \"Article 📚\",\n            topics = listOf(\"1\", \"2\"),\n        )\n\n        val networkTopics = listOf(\n            NetworkTopic(\n                id = \"1\",\n                name = \"Test 1\",\n                shortDescription = \"short description 1\",\n                longDescription = \"long description 1\",\n                url = \"url 1\",\n                imageUrl = \"imageUrl 1\",\n            ),\n            NetworkTopic(\n                id = \"2\",\n                name = \"Test 2\",\n                shortDescription = \"short description 2\",\n                longDescription = \"long description 2\",\n                url = \"url 2\",\n                imageUrl = \"imageUrl 2\",\n            ),\n        )\n\n        val expected = NewsResource(\n            id = \"0\",\n            title = \"title\",\n            content = \"content\",\n            url = \"url\",\n            headerImageUrl = \"headerImageUrl\",\n            publishDate = Instant.fromEpochMilliseconds(1),\n            type = \"Article 📚\",\n            topics = networkTopics.map(NetworkTopic::asExternalModel),\n        )\n        assertEquals(expected, networkNewsResource.asExternalModel(networkTopics))\n    }\n}\n"
  },
  {
    "path": "core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.repository\n\nimport com.google.samples.apps.nowinandroid.core.data.Synchronizer\nimport com.google.samples.apps.nowinandroid.core.data.model.asEntity\nimport com.google.samples.apps.nowinandroid.core.data.model.topicCrossReferences\nimport com.google.samples.apps.nowinandroid.core.data.model.topicEntityShells\nimport com.google.samples.apps.nowinandroid.core.data.testdoubles.CollectionType\nimport com.google.samples.apps.nowinandroid.core.data.testdoubles.TestNewsResourceDao\nimport com.google.samples.apps.nowinandroid.core.data.testdoubles.TestNiaNetworkDataSource\nimport com.google.samples.apps.nowinandroid.core.data.testdoubles.TestTopicDao\nimport com.google.samples.apps.nowinandroid.core.data.testdoubles.filteredInterestsIds\nimport com.google.samples.apps.nowinandroid.core.data.testdoubles.nonPresentInterestsIds\nimport com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity\nimport com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef\nimport com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource\nimport com.google.samples.apps.nowinandroid.core.database.model.TopicEntity\nimport com.google.samples.apps.nowinandroid.core.database.model.asExternalModel\nimport com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource\nimport com.google.samples.apps.nowinandroid.core.datastore.UserPreferences\nimport com.google.samples.apps.nowinandroid.core.datastore.test.InMemoryDataStore\nimport com.google.samples.apps.nowinandroid.core.model.data.NewsResource\nimport com.google.samples.apps.nowinandroid.core.model.data.Topic\nimport com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList\nimport com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource\nimport com.google.samples.apps.nowinandroid.core.testing.notifications.TestNotifier\nimport kotlinx.coroutines.flow.first\nimport kotlinx.coroutines.test.TestScope\nimport kotlinx.coroutines.test.UnconfinedTestDispatcher\nimport kotlinx.coroutines.test.runTest\nimport org.junit.Before\nimport org.junit.Test\nimport kotlin.test.assertEquals\nimport kotlin.test.assertTrue\n\nclass OfflineFirstNewsRepositoryTest {\n\n    private val testScope = TestScope(UnconfinedTestDispatcher())\n\n    private lateinit var subject: OfflineFirstNewsRepository\n\n    private lateinit var niaPreferencesDataSource: NiaPreferencesDataSource\n\n    private lateinit var newsResourceDao: TestNewsResourceDao\n\n    private lateinit var topicDao: TestTopicDao\n\n    private lateinit var network: TestNiaNetworkDataSource\n\n    private lateinit var notifier: TestNotifier\n\n    private lateinit var synchronizer: Synchronizer\n\n    @Before\n    fun setup() {\n        niaPreferencesDataSource = NiaPreferencesDataSource(InMemoryDataStore(UserPreferences.getDefaultInstance()))\n        newsResourceDao = TestNewsResourceDao()\n        topicDao = TestTopicDao()\n        network = TestNiaNetworkDataSource()\n        notifier = TestNotifier()\n        synchronizer = TestSynchronizer(\n            niaPreferencesDataSource,\n        )\n\n        subject = OfflineFirstNewsRepository(\n            niaPreferencesDataSource = niaPreferencesDataSource,\n            newsResourceDao = newsResourceDao,\n            topicDao = topicDao,\n            network = network,\n            notifier = notifier,\n        )\n    }\n\n    @Test\n    fun offlineFirstNewsRepository_news_resources_stream_is_backed_by_news_resource_dao() =\n        testScope.runTest {\n            subject.syncWith(synchronizer)\n            assertEquals(\n                newsResourceDao.getNewsResources()\n                    .first()\n                    .map(PopulatedNewsResource::asExternalModel),\n                subject.getNewsResources()\n                    .first(),\n            )\n        }\n\n    @Test\n    fun offlineFirstNewsRepository_news_resources_for_topic_is_backed_by_news_resource_dao() =\n        testScope.runTest {\n            assertEquals(\n                expected = newsResourceDao.getNewsResources(\n                    filterTopicIds = filteredInterestsIds,\n                    useFilterTopicIds = true,\n                )\n                    .first()\n                    .map(PopulatedNewsResource::asExternalModel),\n                actual = subject.getNewsResources(\n                    query = NewsResourceQuery(\n                        filterTopicIds = filteredInterestsIds,\n                    ),\n                )\n                    .first(),\n            )\n\n            assertEquals(\n                expected = emptyList(),\n                actual = subject.getNewsResources(\n                    query = NewsResourceQuery(\n                        filterTopicIds = nonPresentInterestsIds,\n                    ),\n                )\n                    .first(),\n            )\n        }\n\n    @Test\n    fun offlineFirstNewsRepository_sync_pulls_from_network() =\n        testScope.runTest {\n            // User has not onboarded\n            niaPreferencesDataSource.setShouldHideOnboarding(false)\n            subject.syncWith(synchronizer)\n\n            val newsResourcesFromNetwork = network.getNewsResources()\n                .map(NetworkNewsResource::asEntity)\n                .map(NewsResourceEntity::asExternalModel)\n\n            val newsResourcesFromDb = newsResourceDao.getNewsResources()\n                .first()\n                .map(PopulatedNewsResource::asExternalModel)\n\n            assertEquals(\n                newsResourcesFromNetwork.map(NewsResource::id).sorted(),\n                newsResourcesFromDb.map(NewsResource::id).sorted(),\n            )\n\n            // After sync version should be updated\n            assertEquals(\n                expected = network.latestChangeListVersion(CollectionType.NewsResources),\n                actual = synchronizer.getChangeListVersions().newsResourceVersion,\n            )\n\n            // Notifier should not have been called\n            assertTrue(notifier.addedNewsResources.isEmpty())\n        }\n\n    @Test\n    fun offlineFirstNewsRepository_sync_deletes_items_marked_deleted_on_network() =\n        testScope.runTest {\n            // User has not onboarded\n            niaPreferencesDataSource.setShouldHideOnboarding(false)\n\n            val newsResourcesFromNetwork = network.getNewsResources()\n                .map(NetworkNewsResource::asEntity)\n                .map(NewsResourceEntity::asExternalModel)\n\n            // Delete half of the items on the network\n            val deletedItems = newsResourcesFromNetwork\n                .map(NewsResource::id)\n                .partition { it.chars().sum() % 2 == 0 }\n                .first\n                .toSet()\n\n            deletedItems.forEach {\n                network.editCollection(\n                    collectionType = CollectionType.NewsResources,\n                    id = it,\n                    isDelete = true,\n                )\n            }\n\n            subject.syncWith(synchronizer)\n\n            val newsResourcesFromDb = newsResourceDao.getNewsResources()\n                .first()\n                .map(PopulatedNewsResource::asExternalModel)\n\n            // Assert that items marked deleted on the network have been deleted locally\n            assertEquals(\n                expected = (newsResourcesFromNetwork.map(NewsResource::id) - deletedItems).sorted(),\n                actual = newsResourcesFromDb.map(NewsResource::id).sorted(),\n            )\n\n            // After sync version should be updated\n            assertEquals(\n                expected = network.latestChangeListVersion(CollectionType.NewsResources),\n                actual = synchronizer.getChangeListVersions().newsResourceVersion,\n            )\n\n            // Notifier should not have been called\n            assertTrue(notifier.addedNewsResources.isEmpty())\n        }\n\n    @Test\n    fun offlineFirstNewsRepository_incremental_sync_pulls_from_network() =\n        testScope.runTest {\n            // User has not onboarded\n            niaPreferencesDataSource.setShouldHideOnboarding(false)\n\n            // Set news version to 7\n            synchronizer.updateChangeListVersions {\n                copy(newsResourceVersion = 7)\n            }\n\n            subject.syncWith(synchronizer)\n\n            val changeList = network.changeListsAfter(\n                CollectionType.NewsResources,\n                version = 7,\n            )\n            val changeListIds = changeList\n                .map(NetworkChangeList::id)\n                .toSet()\n\n            val newsResourcesFromNetwork = network.getNewsResources()\n                .map(NetworkNewsResource::asEntity)\n                .map(NewsResourceEntity::asExternalModel)\n                .filter { it.id in changeListIds }\n\n            val newsResourcesFromDb = newsResourceDao.getNewsResources()\n                .first()\n                .map(PopulatedNewsResource::asExternalModel)\n\n            assertEquals(\n                expected = newsResourcesFromNetwork.map(NewsResource::id).sorted(),\n                actual = newsResourcesFromDb.map(NewsResource::id).sorted(),\n            )\n\n            // After sync version should be updated\n            assertEquals(\n                expected = changeList.last().changeListVersion,\n                actual = synchronizer.getChangeListVersions().newsResourceVersion,\n            )\n\n            // Notifier should not have been called\n            assertTrue(notifier.addedNewsResources.isEmpty())\n        }\n\n    @Test\n    fun offlineFirstNewsRepository_sync_saves_shell_topic_entities() =\n        testScope.runTest {\n            subject.syncWith(synchronizer)\n\n            assertEquals(\n                expected = network.getNewsResources()\n                    .map(NetworkNewsResource::topicEntityShells)\n                    .flatten()\n                    .distinctBy(TopicEntity::id)\n                    .sortedBy(TopicEntity::toString),\n                actual = topicDao.getTopicEntities()\n                    .first()\n                    .sortedBy(TopicEntity::toString),\n            )\n        }\n\n    @Test\n    fun offlineFirstNewsRepository_sync_saves_topic_cross_references() =\n        testScope.runTest {\n            subject.syncWith(synchronizer)\n\n            assertEquals(\n                expected = network.getNewsResources()\n                    .map(NetworkNewsResource::topicCrossReferences)\n                    .flatten()\n                    .distinct()\n                    .sortedBy(NewsResourceTopicCrossRef::toString),\n                actual = newsResourceDao.topicCrossReferences\n                    .sortedBy(NewsResourceTopicCrossRef::toString),\n            )\n        }\n\n    @Test\n    fun offlineFirstNewsRepository_sync_marks_as_read_on_first_run() =\n        testScope.runTest {\n            subject.syncWith(synchronizer)\n\n            assertEquals(\n                network.getNewsResources().map { it.id }.toSet(),\n                niaPreferencesDataSource.userData.first().viewedNewsResources,\n            )\n        }\n\n    @Test\n    fun offlineFirstNewsRepository_sync_does_not_mark_as_read_on_subsequent_run() =\n        testScope.runTest {\n            // Pretend that we already have up to change list 7\n            synchronizer.updateChangeListVersions {\n                copy(newsResourceVersion = 7)\n            }\n\n            subject.syncWith(synchronizer)\n\n            assertEquals(\n                emptySet(),\n                niaPreferencesDataSource.userData.first().viewedNewsResources,\n            )\n        }\n\n    @Test\n    fun offlineFirstNewsRepository_sends_notifications_for_newly_synced_news_that_is_followed() =\n        testScope.runTest {\n            // User has onboarded\n            niaPreferencesDataSource.setShouldHideOnboarding(true)\n\n            val networkNewsResources = network.getNewsResources()\n\n            // Follow roughly half the topics\n            val followedTopicIds = networkNewsResources\n                .flatMap(NetworkNewsResource::topicEntityShells)\n                .mapNotNull { topic ->\n                    when (topic.id.chars().sum() % 2) {\n                        0 -> topic.id\n                        else -> null\n                    }\n                }\n                .toSet()\n\n            // Set followed topics\n            niaPreferencesDataSource.setFollowedTopicIds(followedTopicIds)\n\n            subject.syncWith(synchronizer)\n\n            val followedNewsResourceIdsFromNetwork = networkNewsResources\n                .filter { (it.topics intersect followedTopicIds).isNotEmpty() }\n                .map(NetworkNewsResource::id)\n                .sorted()\n\n            // Notifier should have been called with only news resources that have topics\n            // that the user follows\n            assertEquals(\n                expected = followedNewsResourceIdsFromNetwork,\n                actual = notifier.addedNewsResources.first().map(NewsResource::id).sorted(),\n            )\n        }\n\n    @Test\n    fun offlineFirstNewsRepository_does_not_send_notifications_for_existing_news_resources() =\n        testScope.runTest {\n            // User has onboarded\n            niaPreferencesDataSource.setShouldHideOnboarding(true)\n\n            val networkNewsResources = network.getNewsResources()\n                .map(NetworkNewsResource::asEntity)\n\n            val newsResources = networkNewsResources\n                .map(NewsResourceEntity::asExternalModel)\n\n            // Prepopulate dao with news resources\n            newsResourceDao.upsertNewsResources(networkNewsResources)\n\n            val followedTopicIds = newsResources\n                .flatMap(NewsResource::topics)\n                .map(Topic::id)\n                .toSet()\n\n            // Follow all topics\n            niaPreferencesDataSource.setFollowedTopicIds(followedTopicIds)\n\n            subject.syncWith(synchronizer)\n\n            // Notifier should not have been called bc all news resources existed previously\n            assertTrue(notifier.addedNewsResources.isEmpty())\n        }\n}\n"
  },
  {
    "path": "core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstTopicsRepositoryTest.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.repository\n\nimport com.google.samples.apps.nowinandroid.core.data.Synchronizer\nimport com.google.samples.apps.nowinandroid.core.data.model.asEntity\nimport com.google.samples.apps.nowinandroid.core.data.testdoubles.CollectionType\nimport com.google.samples.apps.nowinandroid.core.data.testdoubles.TestNiaNetworkDataSource\nimport com.google.samples.apps.nowinandroid.core.data.testdoubles.TestTopicDao\nimport com.google.samples.apps.nowinandroid.core.database.dao.TopicDao\nimport com.google.samples.apps.nowinandroid.core.database.model.TopicEntity\nimport com.google.samples.apps.nowinandroid.core.database.model.asExternalModel\nimport com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource\nimport com.google.samples.apps.nowinandroid.core.datastore.UserPreferences\nimport com.google.samples.apps.nowinandroid.core.datastore.test.InMemoryDataStore\nimport com.google.samples.apps.nowinandroid.core.model.data.Topic\nimport com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic\nimport kotlinx.coroutines.flow.first\nimport kotlinx.coroutines.test.TestScope\nimport kotlinx.coroutines.test.UnconfinedTestDispatcher\nimport kotlinx.coroutines.test.runTest\nimport org.junit.Before\nimport org.junit.Test\nimport kotlin.test.assertEquals\n\nclass OfflineFirstTopicsRepositoryTest {\n\n    private val testScope = TestScope(UnconfinedTestDispatcher())\n\n    private lateinit var subject: OfflineFirstTopicsRepository\n\n    private lateinit var topicDao: TopicDao\n\n    private lateinit var network: TestNiaNetworkDataSource\n\n    private lateinit var niaPreferences: NiaPreferencesDataSource\n\n    private lateinit var synchronizer: Synchronizer\n\n    @Before\n    fun setup() {\n        topicDao = TestTopicDao()\n        network = TestNiaNetworkDataSource()\n        niaPreferences = NiaPreferencesDataSource(InMemoryDataStore(UserPreferences.getDefaultInstance()))\n        synchronizer = TestSynchronizer(niaPreferences)\n\n        subject = OfflineFirstTopicsRepository(\n            topicDao = topicDao,\n            network = network,\n        )\n    }\n\n    @Test\n    fun offlineFirstTopicsRepository_topics_stream_is_backed_by_topics_dao() =\n        testScope.runTest {\n            subject.syncWith(synchronizer)\n\n            assertEquals(\n                topicDao.getTopicEntities()\n                    .first()\n                    .map(TopicEntity::asExternalModel),\n                subject.getTopics()\n                    .first(),\n            )\n        }\n\n    @Test\n    fun offlineFirstTopicsRepository_sync_pulls_from_network() =\n        testScope.runTest {\n            subject.syncWith(synchronizer)\n\n            val networkTopics = network.getTopics()\n                .map(NetworkTopic::asEntity)\n\n            val dbTopics = topicDao.getTopicEntities()\n                .first()\n\n            assertEquals(\n                networkTopics.map(TopicEntity::id),\n                dbTopics.map(TopicEntity::id),\n            )\n\n            // After sync version should be updated\n            assertEquals(\n                network.latestChangeListVersion(CollectionType.Topics),\n                synchronizer.getChangeListVersions().topicVersion,\n            )\n        }\n\n    @Test\n    fun offlineFirstTopicsRepository_incremental_sync_pulls_from_network() =\n        testScope.runTest {\n            // Set topics version to 10\n            synchronizer.updateChangeListVersions {\n                copy(topicVersion = 10)\n            }\n\n            subject.syncWith(synchronizer)\n\n            val networkTopics = network.getTopics()\n                .map(NetworkTopic::asEntity)\n                // Drop 10 to simulate the first 10 items being unchanged\n                .drop(10)\n\n            val dbTopics = topicDao.getTopicEntities()\n                .first()\n\n            assertEquals(\n                networkTopics.map(TopicEntity::id),\n                dbTopics.map(TopicEntity::id),\n            )\n\n            // After sync version should be updated\n            assertEquals(\n                network.latestChangeListVersion(CollectionType.Topics),\n                synchronizer.getChangeListVersions().topicVersion,\n            )\n        }\n\n    @Test\n    fun offlineFirstTopicsRepository_sync_deletes_items_marked_deleted_on_network() =\n        testScope.runTest {\n            val networkTopics = network.getTopics()\n                .map(NetworkTopic::asEntity)\n                .map(TopicEntity::asExternalModel)\n\n            // Delete half of the items on the network\n            val deletedItems = networkTopics\n                .map(Topic::id)\n                .partition { it.chars().sum() % 2 == 0 }\n                .first\n                .toSet()\n\n            deletedItems.forEach {\n                network.editCollection(\n                    collectionType = CollectionType.Topics,\n                    id = it,\n                    isDelete = true,\n                )\n            }\n\n            subject.syncWith(synchronizer)\n\n            val dbTopics = topicDao.getTopicEntities()\n                .first()\n                .map(TopicEntity::asExternalModel)\n\n            // Assert that items marked deleted on the network have been deleted locally\n            assertEquals(\n                networkTopics.map(Topic::id) - deletedItems,\n                dbTopics.map(Topic::id),\n            )\n\n            // After sync version should be updated\n            assertEquals(\n                network.latestChangeListVersion(CollectionType.Topics),\n                synchronizer.getChangeListVersions().topicVersion,\n            )\n        }\n}\n"
  },
  {
    "path": "core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.repository\n\nimport com.google.samples.apps.nowinandroid.core.analytics.NoOpAnalyticsHelper\nimport com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource\nimport com.google.samples.apps.nowinandroid.core.datastore.UserPreferences\nimport com.google.samples.apps.nowinandroid.core.datastore.test.InMemoryDataStore\nimport com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig\nimport com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand\nimport com.google.samples.apps.nowinandroid.core.model.data.UserData\nimport kotlinx.coroutines.flow.first\nimport kotlinx.coroutines.flow.map\nimport kotlinx.coroutines.test.TestScope\nimport kotlinx.coroutines.test.UnconfinedTestDispatcher\nimport kotlinx.coroutines.test.runTest\nimport org.junit.Before\nimport org.junit.Test\nimport kotlin.test.assertEquals\nimport kotlin.test.assertFalse\nimport kotlin.test.assertTrue\n\nclass OfflineFirstUserDataRepositoryTest {\n\n    private val testScope = TestScope(UnconfinedTestDispatcher())\n\n    private lateinit var subject: OfflineFirstUserDataRepository\n\n    private lateinit var niaPreferencesDataSource: NiaPreferencesDataSource\n\n    private val analyticsHelper = NoOpAnalyticsHelper()\n\n    @Before\n    fun setup() {\n        niaPreferencesDataSource = NiaPreferencesDataSource(InMemoryDataStore(UserPreferences.getDefaultInstance()))\n\n        subject = OfflineFirstUserDataRepository(\n            niaPreferencesDataSource = niaPreferencesDataSource,\n            analyticsHelper,\n        )\n    }\n\n    @Test\n    fun offlineFirstUserDataRepository_default_user_data_is_correct() =\n        testScope.runTest {\n            assertEquals(\n                UserData(\n                    bookmarkedNewsResources = emptySet(),\n                    viewedNewsResources = emptySet(),\n                    followedTopics = emptySet(),\n                    themeBrand = ThemeBrand.DEFAULT,\n                    darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM,\n                    useDynamicColor = false,\n                    shouldHideOnboarding = false,\n                ),\n                subject.userData.first(),\n            )\n        }\n\n    @Test\n    fun offlineFirstUserDataRepository_toggle_followed_topics_logic_delegates_to_nia_preferences() =\n        testScope.runTest {\n            subject.setTopicIdFollowed(followedTopicId = \"0\", followed = true)\n\n            assertEquals(\n                setOf(\"0\"),\n                subject.userData\n                    .map { it.followedTopics }\n                    .first(),\n            )\n\n            subject.setTopicIdFollowed(followedTopicId = \"1\", followed = true)\n\n            assertEquals(\n                setOf(\"0\", \"1\"),\n                subject.userData\n                    .map { it.followedTopics }\n                    .first(),\n            )\n\n            assertEquals(\n                niaPreferencesDataSource.userData\n                    .map { it.followedTopics }\n                    .first(),\n                subject.userData\n                    .map { it.followedTopics }\n                    .first(),\n            )\n        }\n\n    @Test\n    fun offlineFirstUserDataRepository_set_followed_topics_logic_delegates_to_nia_preferences() =\n        testScope.runTest {\n            subject.setFollowedTopicIds(followedTopicIds = setOf(\"1\", \"2\"))\n\n            assertEquals(\n                setOf(\"1\", \"2\"),\n                subject.userData\n                    .map { it.followedTopics }\n                    .first(),\n            )\n\n            assertEquals(\n                niaPreferencesDataSource.userData\n                    .map { it.followedTopics }\n                    .first(),\n                subject.userData\n                    .map { it.followedTopics }\n                    .first(),\n            )\n        }\n\n    @Test\n    fun offlineFirstUserDataRepository_bookmark_news_resource_logic_delegates_to_nia_preferences() =\n        testScope.runTest {\n            subject.setNewsResourceBookmarked(newsResourceId = \"0\", bookmarked = true)\n\n            assertEquals(\n                setOf(\"0\"),\n                subject.userData\n                    .map { it.bookmarkedNewsResources }\n                    .first(),\n            )\n\n            subject.setNewsResourceBookmarked(newsResourceId = \"1\", bookmarked = true)\n\n            assertEquals(\n                setOf(\"0\", \"1\"),\n                subject.userData\n                    .map { it.bookmarkedNewsResources }\n                    .first(),\n            )\n\n            assertEquals(\n                niaPreferencesDataSource.userData\n                    .map { it.bookmarkedNewsResources }\n                    .first(),\n                subject.userData\n                    .map { it.bookmarkedNewsResources }\n                    .first(),\n            )\n        }\n\n    @Test\n    fun offlineFirstUserDataRepository_update_viewed_news_resources_delegates_to_nia_preferences() =\n        runTest {\n            subject.setNewsResourceViewed(newsResourceId = \"0\", viewed = true)\n\n            assertEquals(\n                setOf(\"0\"),\n                subject.userData\n                    .map { it.viewedNewsResources }\n                    .first(),\n            )\n\n            subject.setNewsResourceViewed(newsResourceId = \"1\", viewed = true)\n\n            assertEquals(\n                setOf(\"0\", \"1\"),\n                subject.userData\n                    .map { it.viewedNewsResources }\n                    .first(),\n            )\n\n            assertEquals(\n                niaPreferencesDataSource.userData\n                    .map { it.viewedNewsResources }\n                    .first(),\n                subject.userData\n                    .map { it.viewedNewsResources }\n                    .first(),\n            )\n        }\n\n    @Test\n    fun offlineFirstUserDataRepository_set_theme_brand_delegates_to_nia_preferences() =\n        testScope.runTest {\n            subject.setThemeBrand(ThemeBrand.ANDROID)\n\n            assertEquals(\n                ThemeBrand.ANDROID,\n                subject.userData\n                    .map { it.themeBrand }\n                    .first(),\n            )\n            assertEquals(\n                ThemeBrand.ANDROID,\n                niaPreferencesDataSource\n                    .userData\n                    .map { it.themeBrand }\n                    .first(),\n            )\n        }\n\n    @Test\n    fun offlineFirstUserDataRepository_set_dynamic_color_delegates_to_nia_preferences() =\n        testScope.runTest {\n            subject.setDynamicColorPreference(true)\n\n            assertEquals(\n                true,\n                subject.userData\n                    .map { it.useDynamicColor }\n                    .first(),\n            )\n            assertEquals(\n                true,\n                niaPreferencesDataSource\n                    .userData\n                    .map { it.useDynamicColor }\n                    .first(),\n            )\n        }\n\n    @Test\n    fun offlineFirstUserDataRepository_set_dark_theme_config_delegates_to_nia_preferences() =\n        testScope.runTest {\n            subject.setDarkThemeConfig(DarkThemeConfig.DARK)\n\n            assertEquals(\n                DarkThemeConfig.DARK,\n                subject.userData\n                    .map { it.darkThemeConfig }\n                    .first(),\n            )\n            assertEquals(\n                DarkThemeConfig.DARK,\n                niaPreferencesDataSource\n                    .userData\n                    .map { it.darkThemeConfig }\n                    .first(),\n            )\n        }\n\n    @Test\n    fun whenUserCompletesOnboarding_thenRemovesAllInterests_shouldHideOnboardingIsFalse() =\n        testScope.runTest {\n            subject.setFollowedTopicIds(setOf(\"1\"))\n            subject.setShouldHideOnboarding(true)\n            assertTrue(subject.userData.first().shouldHideOnboarding)\n\n            subject.setFollowedTopicIds(emptySet())\n            assertFalse(subject.userData.first().shouldHideOnboarding)\n        }\n}\n"
  },
  {
    "path": "core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/TestSynchronizer.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.repository\n\nimport com.google.samples.apps.nowinandroid.core.data.Synchronizer\nimport com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions\nimport com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource\n\n/**\n * Test synchronizer that delegates to [NiaPreferencesDataSource]\n */\nclass TestSynchronizer(\n    private val niaPreferences: NiaPreferencesDataSource,\n) : Synchronizer {\n    override suspend fun getChangeListVersions(): ChangeListVersions =\n        niaPreferences.getChangeListVersions()\n\n    override suspend fun updateChangeListVersions(\n        update: ChangeListVersions.() -> ChangeListVersions,\n    ) = niaPreferences.updateChangeListVersion(update)\n}\n"
  },
  {
    "path": "core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.testdoubles\n\nimport com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao\nimport com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity\nimport com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef\nimport com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource\nimport com.google.samples.apps.nowinandroid.core.database.model.TopicEntity\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.map\nimport kotlinx.coroutines.flow.update\n\nval filteredInterestsIds = setOf(\"1\")\nval nonPresentInterestsIds = setOf(\"2\")\n\n/**\n * Test double for [NewsResourceDao]\n */\nclass TestNewsResourceDao : NewsResourceDao {\n\n    private val entitiesStateFlow = MutableStateFlow(emptyList<NewsResourceEntity>())\n\n    internal var topicCrossReferences: List<NewsResourceTopicCrossRef> = emptyList()\n\n    override fun getNewsResources(\n        useFilterTopicIds: Boolean,\n        filterTopicIds: Set<String>,\n        useFilterNewsIds: Boolean,\n        filterNewsIds: Set<String>,\n    ): Flow<List<PopulatedNewsResource>> =\n        entitiesStateFlow\n            .map { newsResourceEntities ->\n                newsResourceEntities.map { entity ->\n                    entity.asPopulatedNewsResource(topicCrossReferences)\n                }\n            }\n            .map { resources ->\n                var result = resources\n                if (useFilterTopicIds) {\n                    result = result.filter { resource ->\n                        resource.topics.any { it.id in filterTopicIds }\n                    }\n                }\n                if (useFilterNewsIds) {\n                    result = result.filter { resource ->\n                        resource.entity.id in filterNewsIds\n                    }\n                }\n                result\n            }\n\n    override fun getNewsResourceIds(\n        useFilterTopicIds: Boolean,\n        filterTopicIds: Set<String>,\n        useFilterNewsIds: Boolean,\n        filterNewsIds: Set<String>,\n    ): Flow<List<String>> =\n        entitiesStateFlow\n            .map { newsResourceEntities ->\n                newsResourceEntities.map { entity ->\n                    entity.asPopulatedNewsResource(topicCrossReferences)\n                }\n            }\n            .map { resources ->\n                var result = resources\n                if (useFilterTopicIds) {\n                    result = result.filter { resource ->\n                        resource.topics.any { it.id in filterTopicIds }\n                    }\n                }\n                if (useFilterNewsIds) {\n                    result = result.filter { resource ->\n                        resource.entity.id in filterNewsIds\n                    }\n                }\n                result.map { it.entity.id }\n            }\n\n    override suspend fun upsertNewsResources(newsResourceEntities: List<NewsResourceEntity>) {\n        entitiesStateFlow.update { oldValues ->\n            // New values come first so they overwrite old values\n            (newsResourceEntities + oldValues)\n                .distinctBy(NewsResourceEntity::id)\n                .sortedWith(\n                    compareBy(NewsResourceEntity::publishDate).reversed(),\n                )\n        }\n    }\n\n    override suspend fun insertOrIgnoreTopicCrossRefEntities(\n        newsResourceTopicCrossReferences: List<NewsResourceTopicCrossRef>,\n    ) {\n        // Keep old values over new ones\n        topicCrossReferences = (topicCrossReferences + newsResourceTopicCrossReferences)\n            .distinctBy { it.newsResourceId to it.topicId }\n    }\n\n    override suspend fun deleteNewsResources(ids: List<String>) {\n        val idSet = ids.toSet()\n        entitiesStateFlow.update { entities ->\n            entities.filterNot { it.id in idSet }\n        }\n    }\n}\n\nprivate fun NewsResourceEntity.asPopulatedNewsResource(\n    topicCrossReferences: List<NewsResourceTopicCrossRef>,\n) = PopulatedNewsResource(\n    entity = this,\n    topics = topicCrossReferences\n        .filter { it.newsResourceId == id }\n        .map { newsResourceTopicCrossRef ->\n            TopicEntity(\n                id = newsResourceTopicCrossRef.topicId,\n                name = \"name\",\n                shortDescription = \"short description\",\n                longDescription = \"long description\",\n                url = \"URL\",\n                imageUrl = \"image URL\",\n            )\n        },\n)\n"
  },
  {
    "path": "core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNiaNetworkDataSource.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.testdoubles\n\nimport com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource\nimport com.google.samples.apps.nowinandroid.core.network.demo.DemoNiaNetworkDataSource\nimport com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList\nimport com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource\nimport com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic\nimport kotlinx.coroutines.runBlocking\nimport kotlinx.coroutines.test.UnconfinedTestDispatcher\nimport kotlinx.serialization.json.Json\n\nenum class CollectionType {\n    Topics,\n    NewsResources,\n}\n\n/**\n * Test double for [NiaNetworkDataSource]\n */\nclass TestNiaNetworkDataSource : NiaNetworkDataSource {\n\n    private val source = DemoNiaNetworkDataSource(\n        UnconfinedTestDispatcher(),\n        Json { ignoreUnknownKeys = true },\n    )\n\n    private val allTopics = runBlocking { source.getTopics() }\n\n    private val allNewsResources = runBlocking { source.getNewsResources() }\n\n    private val changeLists: MutableMap<CollectionType, List<NetworkChangeList>> = mutableMapOf(\n        CollectionType.Topics to allTopics\n            .mapToChangeList(idGetter = NetworkTopic::id),\n        CollectionType.NewsResources to allNewsResources\n            .mapToChangeList(idGetter = NetworkNewsResource::id),\n    )\n\n    override suspend fun getTopics(ids: List<String>?): List<NetworkTopic> =\n        allTopics.matchIds(\n            ids = ids,\n            idGetter = NetworkTopic::id,\n        )\n\n    override suspend fun getNewsResources(ids: List<String>?): List<NetworkNewsResource> =\n        allNewsResources.matchIds(\n            ids = ids,\n            idGetter = NetworkNewsResource::id,\n        )\n\n    override suspend fun getTopicChangeList(after: Int?): List<NetworkChangeList> =\n        changeLists.getValue(CollectionType.Topics).after(after)\n\n    override suspend fun getNewsResourceChangeList(after: Int?): List<NetworkChangeList> =\n        changeLists.getValue(CollectionType.NewsResources).after(after)\n\n    fun latestChangeListVersion(collectionType: CollectionType) =\n        changeLists.getValue(collectionType).last().changeListVersion\n\n    fun changeListsAfter(collectionType: CollectionType, version: Int) =\n        changeLists.getValue(collectionType).after(version)\n\n    /**\n     * Edits the change list for the backing [collectionType] for the given [id] mimicking\n     * the server's change list registry\n     */\n    fun editCollection(collectionType: CollectionType, id: String, isDelete: Boolean) {\n        val changeList = changeLists.getValue(collectionType)\n        val latestVersion = changeList.lastOrNull()?.changeListVersion ?: 0\n        val change = NetworkChangeList(\n            id = id,\n            isDelete = isDelete,\n            changeListVersion = latestVersion + 1,\n        )\n        changeLists[collectionType] = changeList.filterNot { it.id == id } + change\n    }\n}\n\nfun List<NetworkChangeList>.after(version: Int?): List<NetworkChangeList> = when (version) {\n    null -> this\n    else -> filter { it.changeListVersion > version }\n}\n\n/**\n * Return items from [this] whose id defined by [idGetter] is in [ids] if [ids] is not null\n */\nprivate fun <T> List<T>.matchIds(\n    ids: List<String>?,\n    idGetter: (T) -> String,\n) = when (ids) {\n    null -> this\n    else -> ids.toSet().let { idSet -> filter { idGetter(it) in idSet } }\n}\n\n/**\n * Maps items to a change list where the change list version is denoted by the index of each item.\n * [after] simulates which models have changed by excluding items before it\n */\nprivate fun <T> List<T>.mapToChangeList(\n    idGetter: (T) -> String,\n) = mapIndexed { index, item ->\n    NetworkChangeList(\n        id = idGetter(item),\n        changeListVersion = index + 1,\n        isDelete = false,\n    )\n}\n"
  },
  {
    "path": "core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.testdoubles\n\nimport com.google.samples.apps.nowinandroid.core.database.dao.TopicDao\nimport com.google.samples.apps.nowinandroid.core.database.model.TopicEntity\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.map\nimport kotlinx.coroutines.flow.update\n\n/**\n * Test double for [TopicDao]\n */\nclass TestTopicDao : TopicDao {\n\n    private val entitiesStateFlow = MutableStateFlow(emptyList<TopicEntity>())\n\n    override fun getTopicEntity(topicId: String): Flow<TopicEntity> =\n        throw NotImplementedError(\"Unused in tests\")\n\n    override fun getTopicEntities(): Flow<List<TopicEntity>> = entitiesStateFlow\n\n    override fun getTopicEntities(ids: Set<String>): Flow<List<TopicEntity>> =\n        getTopicEntities().map { topics -> topics.filter { it.id in ids } }\n\n    override suspend fun getOneOffTopicEntities(): List<TopicEntity> = emptyList()\n\n    override suspend fun insertOrIgnoreTopics(topicEntities: List<TopicEntity>): List<Long> {\n        // Keep old values over new values\n        entitiesStateFlow.update { oldValues ->\n            (oldValues + topicEntities).distinctBy(TopicEntity::id)\n        }\n        return topicEntities.map { it.id.toLong() }\n    }\n\n    override suspend fun upsertTopics(entities: List<TopicEntity>) {\n        // Overwrite old values with new values\n        entitiesStateFlow.update { oldValues -> (entities + oldValues).distinctBy(TopicEntity::id) }\n    }\n\n    override suspend fun deleteTopics(ids: List<String>) {\n        val idSet = ids.toSet()\n        entitiesStateFlow.update { entities -> entities.filterNot { it.id in idSet } }\n    }\n}\n"
  },
  {
    "path": "core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/database/model/PopulatedNewsResourceKtTest.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.database.model\n\nimport com.google.samples.apps.nowinandroid.core.model.data.NewsResource\nimport com.google.samples.apps.nowinandroid.core.model.data.Topic\nimport kotlinx.datetime.Instant\nimport org.junit.Test\nimport kotlin.test.assertEquals\n\nclass PopulatedNewsResourceKtTest {\n    @Test\n    fun populated_news_resource_can_be_mapped_to_news_resource() {\n        val populatedNewsResource = PopulatedNewsResource(\n            entity = NewsResourceEntity(\n                id = \"1\",\n                title = \"news\",\n                content = \"Hilt\",\n                url = \"url\",\n                headerImageUrl = \"headerImageUrl\",\n                type = \"Video 📺\",\n                publishDate = Instant.fromEpochMilliseconds(1),\n            ),\n            topics = listOf(\n                TopicEntity(\n                    id = \"3\",\n                    name = \"name\",\n                    shortDescription = \"short description\",\n                    longDescription = \"long description\",\n                    url = \"URL\",\n                    imageUrl = \"image URL\",\n                ),\n            ),\n        )\n        val newsResource = populatedNewsResource.asExternalModel()\n\n        assertEquals(\n            NewsResource(\n                id = \"1\",\n                title = \"news\",\n                content = \"Hilt\",\n                url = \"url\",\n                headerImageUrl = \"headerImageUrl\",\n                type = \"Video 📺\",\n                publishDate = Instant.fromEpochMilliseconds(1),\n                topics = listOf(\n                    Topic(\n                        id = \"3\",\n                        name = \"name\",\n                        shortDescription = \"short description\",\n                        longDescription = \"long description\",\n                        url = \"URL\",\n                        imageUrl = \"image URL\",\n                    ),\n                ),\n            ),\n            newsResource,\n        )\n    }\n}\n"
  },
  {
    "path": "core/data-test/.gitignore",
    "content": "/build"
  },
  {
    "path": "core/data-test/README.md",
    "content": "# `:core:data-test`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :core\n    direction TB\n    :core:analytics[analytics]:::android-library\n    :core:common[common]:::jvm-library\n    :core:data[data]:::android-library\n    :core:data-test[data-test]:::android-library\n    :core:database[database]:::android-library\n    :core:datastore[datastore]:::android-library\n    :core:datastore-proto[datastore-proto]:::jvm-library\n    :core:model[model]:::jvm-library\n    :core:network[network]:::android-library\n    :core:notifications[notifications]:::android-library\n  end\n\n  :core:data -.-> :core:analytics\n  :core:data --> :core:common\n  :core:data --> :core:database\n  :core:data --> :core:datastore\n  :core:data --> :core:network\n  :core:data -.-> :core:notifications\n  :core:data-test --> :core:data\n  :core:database --> :core:model\n  :core:datastore -.-> :core:common\n  :core:datastore --> :core:datastore-proto\n  :core:datastore --> :core:model\n  :core:network --> :core:common\n  :core:network --> :core:model\n  :core:notifications -.-> :core:common\n  :core:notifications --> :core:model\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "core/data-test/build.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nplugins {\n    alias(libs.plugins.nowinandroid.android.library)\n    alias(libs.plugins.nowinandroid.hilt)\n}\n\nandroid {\n    namespace = \"com.google.samples.apps.nowinandroid.core.data.test\"\n}\n\ndependencies {\n    api(projects.core.data)\n\n    implementation(libs.hilt.android.testing)\n}\n"
  },
  {
    "path": "core/data-test/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<manifest />\n"
  },
  {
    "path": "core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/AlwaysOnlineNetworkMonitor.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.test\n\nimport com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.flowOf\nimport javax.inject.Inject\n\nclass AlwaysOnlineNetworkMonitor @Inject constructor() : NetworkMonitor {\n    override val isOnline: Flow<Boolean> = flowOf(true)\n}\n"
  },
  {
    "path": "core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/DefaultZoneIdTimeZoneMonitor.kt",
    "content": "/*\n * Copyright 2024 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.test\n\nimport com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.flowOf\nimport kotlinx.datetime.TimeZone\nimport javax.inject.Inject\n\nclass DefaultZoneIdTimeZoneMonitor @Inject constructor() : TimeZoneMonitor {\n    override val currentTimeZone: Flow<TimeZone> = flowOf(TimeZone.of(\"Europe/Warsaw\"))\n}\n"
  },
  {
    "path": "core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.test\n\nimport com.google.samples.apps.nowinandroid.core.data.di.DataModule\nimport com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository\nimport com.google.samples.apps.nowinandroid.core.data.test.repository.FakeNewsRepository\nimport com.google.samples.apps.nowinandroid.core.data.test.repository.FakeRecentSearchRepository\nimport com.google.samples.apps.nowinandroid.core.data.test.repository.FakeSearchContentsRepository\nimport com.google.samples.apps.nowinandroid.core.data.test.repository.FakeTopicsRepository\nimport com.google.samples.apps.nowinandroid.core.data.test.repository.FakeUserDataRepository\nimport com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor\nimport com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor\nimport dagger.Binds\nimport dagger.Module\nimport dagger.hilt.components.SingletonComponent\nimport dagger.hilt.testing.TestInstallIn\n\n@Module\n@TestInstallIn(\n    components = [SingletonComponent::class],\n    replaces = [DataModule::class],\n)\ninternal interface TestDataModule {\n    @Binds\n    fun bindsTopicRepository(\n        fakeTopicsRepository: FakeTopicsRepository,\n    ): TopicsRepository\n\n    @Binds\n    fun bindsNewsResourceRepository(\n        fakeNewsRepository: FakeNewsRepository,\n    ): NewsRepository\n\n    @Binds\n    fun bindsUserDataRepository(\n        userDataRepository: FakeUserDataRepository,\n    ): UserDataRepository\n\n    @Binds\n    fun bindsRecentSearchRepository(\n        recentSearchRepository: FakeRecentSearchRepository,\n    ): RecentSearchRepository\n\n    @Binds\n    fun bindsSearchContentsRepository(\n        searchContentsRepository: FakeSearchContentsRepository,\n    ): SearchContentsRepository\n\n    @Binds\n    fun bindsNetworkMonitor(\n        networkMonitor: AlwaysOnlineNetworkMonitor,\n    ): NetworkMonitor\n\n    @Binds\n    fun binds(impl: DefaultZoneIdTimeZoneMonitor): TimeZoneMonitor\n}\n"
  },
  {
    "path": "core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeNewsRepository.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.test.repository\n\nimport com.google.samples.apps.nowinandroid.core.common.network.Dispatcher\nimport com.google.samples.apps.nowinandroid.core.common.network.NiaDispatchers.IO\nimport com.google.samples.apps.nowinandroid.core.data.Synchronizer\nimport com.google.samples.apps.nowinandroid.core.data.model.asExternalModel\nimport com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery\nimport com.google.samples.apps.nowinandroid.core.model.data.NewsResource\nimport com.google.samples.apps.nowinandroid.core.network.demo.DemoNiaNetworkDataSource\nimport kotlinx.coroutines.CoroutineDispatcher\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.flow\nimport kotlinx.coroutines.flow.flowOn\nimport javax.inject.Inject\n\n/**\n * Fake implementation of the [NewsRepository] that retrieves the news resources from a JSON String.\n *\n * This allows us to run the app with fake data, without needing an internet connection or working\n * backend.\n */\nclass FakeNewsRepository @Inject constructor(\n    @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,\n    private val datasource: DemoNiaNetworkDataSource,\n) : NewsRepository {\n\n    override fun getNewsResources(\n        query: NewsResourceQuery,\n    ): Flow<List<NewsResource>> =\n        flow {\n            val newsResources = datasource.getNewsResources()\n            val topics = datasource.getTopics()\n\n            emit(\n                newsResources\n                    .filter { networkNewsResource ->\n                        // Filter out any news resources which don't match the current query.\n                        // If no query parameters (filterTopicIds or filterNewsIds) are specified\n                        // then the news resource is returned.\n                        listOfNotNull(\n                            true,\n                            query.filterNewsIds?.contains(networkNewsResource.id),\n                            query.filterTopicIds?.let { filterTopicIds ->\n                                networkNewsResource.topics.intersect(filterTopicIds).isNotEmpty()\n                            },\n                        )\n                            .all(true::equals)\n                    }\n                    .map { it.asExternalModel(topics) },\n            )\n        }.flowOn(ioDispatcher)\n\n    override suspend fun syncWith(synchronizer: Synchronizer) = true\n}\n"
  },
  {
    "path": "core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeRecentSearchRepository.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.test.repository\n\nimport com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery\nimport com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.flowOf\nimport javax.inject.Inject\n\n/**\n * Fake implementation of the [RecentSearchRepository]\n */\ninternal class FakeRecentSearchRepository @Inject constructor() : RecentSearchRepository {\n    override suspend fun insertOrReplaceRecentSearch(searchQuery: String) = Unit\n\n    override fun getRecentSearchQueries(limit: Int): Flow<List<RecentSearchQuery>> =\n        flowOf(emptyList())\n\n    override suspend fun clearRecentSearches() = Unit\n}\n"
  },
  {
    "path": "core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeSearchContentsRepository.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.test.repository\n\nimport com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository\nimport com.google.samples.apps.nowinandroid.core.model.data.SearchResult\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.flowOf\nimport javax.inject.Inject\n\n/**\n * Fake implementation of the [SearchContentsRepository]\n */\ninternal class FakeSearchContentsRepository @Inject constructor() : SearchContentsRepository {\n\n    override suspend fun populateFtsData() = Unit\n    override fun searchContents(searchQuery: String): Flow<SearchResult> = flowOf()\n    override fun getSearchContentsCount(): Flow<Int> = flowOf(1)\n}\n"
  },
  {
    "path": "core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeTopicsRepository.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.test.repository\n\nimport com.google.samples.apps.nowinandroid.core.common.network.Dispatcher\nimport com.google.samples.apps.nowinandroid.core.common.network.NiaDispatchers.IO\nimport com.google.samples.apps.nowinandroid.core.data.Synchronizer\nimport com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository\nimport com.google.samples.apps.nowinandroid.core.model.data.Topic\nimport com.google.samples.apps.nowinandroid.core.network.demo.DemoNiaNetworkDataSource\nimport kotlinx.coroutines.CoroutineDispatcher\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.flow\nimport kotlinx.coroutines.flow.flowOn\nimport kotlinx.coroutines.flow.map\nimport javax.inject.Inject\n\n/**\n * Fake implementation of the [TopicsRepository] that retrieves the topics from a JSON String, and\n * uses a local DataStore instance to save and retrieve followed topic ids.\n *\n * This allows us to run the app with fake data, without needing an internet connection or working\n * backend.\n */\ninternal class FakeTopicsRepository @Inject constructor(\n    @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,\n    private val datasource: DemoNiaNetworkDataSource,\n) : TopicsRepository {\n    override fun getTopics(): Flow<List<Topic>> = flow {\n        emit(\n            datasource.getTopics().map {\n                Topic(\n                    id = it.id,\n                    name = it.name,\n                    shortDescription = it.shortDescription,\n                    longDescription = it.longDescription,\n                    url = it.url,\n                    imageUrl = it.imageUrl,\n                )\n            },\n        )\n    }.flowOn(ioDispatcher)\n\n    override fun getTopic(id: String): Flow<Topic> = getTopics()\n        .map { it.first { topic -> topic.id == id } }\n\n    override suspend fun syncWith(synchronizer: Synchronizer) = true\n}\n"
  },
  {
    "path": "core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeUserDataRepository.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.data.test.repository\n\nimport com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository\nimport com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource\nimport com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig\nimport com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand\nimport com.google.samples.apps.nowinandroid.core.model.data.UserData\nimport kotlinx.coroutines.flow.Flow\nimport javax.inject.Inject\n\n/**\n * Fake implementation of the [UserDataRepository] that returns hardcoded user data.\n *\n * This allows us to run the app with fake data, without needing an internet connection or working\n * backend.\n */\nclass FakeUserDataRepository @Inject constructor(\n    private val niaPreferencesDataSource: NiaPreferencesDataSource,\n) : UserDataRepository {\n\n    override val userData: Flow<UserData> =\n        niaPreferencesDataSource.userData\n\n    override suspend fun setFollowedTopicIds(followedTopicIds: Set<String>) =\n        niaPreferencesDataSource.setFollowedTopicIds(followedTopicIds)\n\n    override suspend fun setTopicIdFollowed(followedTopicId: String, followed: Boolean) =\n        niaPreferencesDataSource.setTopicIdFollowed(followedTopicId, followed)\n\n    override suspend fun setNewsResourceBookmarked(newsResourceId: String, bookmarked: Boolean) {\n        niaPreferencesDataSource.setNewsResourceBookmarked(newsResourceId, bookmarked)\n    }\n\n    override suspend fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) =\n        niaPreferencesDataSource.setNewsResourceViewed(newsResourceId, viewed)\n\n    override suspend fun setThemeBrand(themeBrand: ThemeBrand) {\n        niaPreferencesDataSource.setThemeBrand(themeBrand)\n    }\n\n    override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) {\n        niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig)\n    }\n\n    override suspend fun setDynamicColorPreference(useDynamicColor: Boolean) {\n        niaPreferencesDataSource.setDynamicColorPreference(useDynamicColor)\n    }\n\n    override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) {\n        niaPreferencesDataSource.setShouldHideOnboarding(shouldHideOnboarding)\n    }\n}\n"
  },
  {
    "path": "core/database/.gitignore",
    "content": "/build"
  },
  {
    "path": "core/database/README.md",
    "content": "# `:core:database`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :core\n    direction TB\n    :core:database[database]:::android-library\n    :core:model[model]:::jvm-library\n  end\n\n  :core:database --> :core:model\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "core/database/build.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nplugins {\n    alias(libs.plugins.nowinandroid.android.library)\n    alias(libs.plugins.nowinandroid.android.library.jacoco)\n    alias(libs.plugins.nowinandroid.android.room)\n    alias(libs.plugins.nowinandroid.hilt)\n}\n\nandroid {\n    namespace = \"com.google.samples.apps.nowinandroid.core.database\"\n}\n\ndependencies {\n    api(projects.core.model)\n\n    implementation(libs.kotlinx.datetime)\n\n    androidTestImplementation(libs.androidx.test.core)\n    androidTestImplementation(libs.androidx.test.runner)\n    androidTestImplementation(libs.kotlinx.coroutines.test)\n}\n"
  },
  {
    "path": "core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/1.json",
    "content": "{\n  \"formatVersion\": 1,\n  \"database\": {\n    \"version\": 1,\n    \"identityHash\": \"004a7c73c822c1e23e409f8160e69317\",\n    \"entities\": [\n      {\n        \"tableName\": \"authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `image_url` TEXT NOT NULL, PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"imageUrl\",\n            \"columnName\": \"image_url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_authors_name\",\n            \"unique\": true,\n            \"columnNames\": [\n              \"name\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE UNIQUE INDEX IF NOT EXISTS `index_authors_name` ON `${TABLE_NAME}` (`name`)\"\n          }\n        ],\n        \"foreignKeys\": []\n      },\n      {\n        \"tableName\": \"episodes_authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episode_id` INTEGER NOT NULL, `author_id` INTEGER NOT NULL, PRIMARY KEY(`episode_id`, `author_id`), FOREIGN KEY(`episode_id`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`author_id`) REFERENCES `authors`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"episodeId\",\n            \"columnName\": \"episode_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"authorId\",\n            \"columnName\": \"author_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"episode_id\",\n            \"author_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": [\n          {\n            \"table\": \"episodes\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"episode_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"authors\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"author_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"episodes\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `publish_date` INTEGER NOT NULL, `alternate_video` TEXT, `alternate_audio` TEXT, PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"publishDate\",\n            \"columnName\": \"publish_date\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"alternateVideo\",\n            \"columnName\": \"alternate_video\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          },\n          {\n            \"fieldPath\": \"alternateAudio\",\n            \"columnName\": \"alternate_audio\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      },\n      {\n        \"tableName\": \"news_resources_authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` INTEGER NOT NULL, `author_id` INTEGER NOT NULL, PRIMARY KEY(`news_resource_id`, `author_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`author_id`) REFERENCES `authors`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"newsResourceId\",\n            \"columnName\": \"news_resource_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"authorId\",\n            \"columnName\": \"author_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"news_resource_id\",\n            \"author_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": [\n          {\n            \"table\": \"news_resources\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"news_resource_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"authors\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"author_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"news_resources\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `episode_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `url` TEXT NOT NULL, `publish_date` INTEGER NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`episode_id`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"episodeId\",\n            \"columnName\": \"episode_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"title\",\n            \"columnName\": \"title\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"content\",\n            \"columnName\": \"content\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"url\",\n            \"columnName\": \"url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"publishDate\",\n            \"columnName\": \"publish_date\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"type\",\n            \"columnName\": \"type\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": [\n          {\n            \"table\": \"episodes\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"episode_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"news_resources_topics\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` INTEGER NOT NULL, `topic_id` INTEGER NOT NULL, PRIMARY KEY(`news_resource_id`, `topic_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`topic_id`) REFERENCES `topics`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"newsResourceId\",\n            \"columnName\": \"news_resource_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"topicId\",\n            \"columnName\": \"topic_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"news_resource_id\",\n            \"topic_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": [\n          {\n            \"table\": \"news_resources\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"news_resource_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"topics\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"topic_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"topics\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"description\",\n            \"columnName\": \"description\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_topics_name\",\n            \"unique\": true,\n            \"columnNames\": [\n              \"name\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE UNIQUE INDEX IF NOT EXISTS `index_topics_name` ON `${TABLE_NAME}` (`name`)\"\n          }\n        ],\n        \"foreignKeys\": []\n      }\n    ],\n    \"views\": [],\n    \"setupQueries\": [\n      \"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)\",\n      \"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '004a7c73c822c1e23e409f8160e69317')\"\n    ]\n  }\n}"
  },
  {
    "path": "core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/10.json",
    "content": "{\n  \"formatVersion\": 1,\n  \"database\": {\n    \"version\": 10,\n    \"identityHash\": \"d90e438da4b3b9cf552df536431dacb3\",\n    \"entities\": [\n      {\n        \"tableName\": \"authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `image_url` TEXT NOT NULL, `twitter` TEXT NOT NULL DEFAULT '', `medium_page` TEXT NOT NULL DEFAULT '', `bio` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"imageUrl\",\n            \"columnName\": \"image_url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"twitter\",\n            \"columnName\": \"twitter\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"mediumPage\",\n            \"columnName\": \"medium_page\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"bio\",\n            \"columnName\": \"bio\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      },\n      {\n        \"tableName\": \"episodes_authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episode_id` TEXT NOT NULL, `author_id` TEXT NOT NULL, PRIMARY KEY(`episode_id`, `author_id`), FOREIGN KEY(`episode_id`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`author_id`) REFERENCES `authors`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"episodeId\",\n            \"columnName\": \"episode_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"authorId\",\n            \"columnName\": \"author_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"episode_id\",\n            \"author_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_episodes_authors_episode_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"episode_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_episodes_authors_episode_id` ON `${TABLE_NAME}` (`episode_id`)\"\n          },\n          {\n            \"name\": \"index_episodes_authors_author_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"author_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_episodes_authors_author_id` ON `${TABLE_NAME}` (`author_id`)\"\n          }\n        ],\n        \"foreignKeys\": [\n          {\n            \"table\": \"episodes\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"episode_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"authors\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"author_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"episodes\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `publish_date` INTEGER NOT NULL, `alternate_video` TEXT, `alternate_audio` TEXT, PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"publishDate\",\n            \"columnName\": \"publish_date\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"alternateVideo\",\n            \"columnName\": \"alternate_video\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          },\n          {\n            \"fieldPath\": \"alternateAudio\",\n            \"columnName\": \"alternate_audio\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      },\n      {\n        \"tableName\": \"news_resources_authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` TEXT NOT NULL, `author_id` TEXT NOT NULL, PRIMARY KEY(`news_resource_id`, `author_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`author_id`) REFERENCES `authors`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"newsResourceId\",\n            \"columnName\": \"news_resource_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"authorId\",\n            \"columnName\": \"author_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"news_resource_id\",\n            \"author_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_news_resources_authors_news_resource_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"news_resource_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_authors_news_resource_id` ON `${TABLE_NAME}` (`news_resource_id`)\"\n          },\n          {\n            \"name\": \"index_news_resources_authors_author_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"author_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_authors_author_id` ON `${TABLE_NAME}` (`author_id`)\"\n          }\n        ],\n        \"foreignKeys\": [\n          {\n            \"table\": \"news_resources\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"news_resource_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"authors\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"author_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"news_resources\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `episode_id` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `url` TEXT NOT NULL, `header_image_url` TEXT, `publish_date` INTEGER NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`episode_id`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"episodeId\",\n            \"columnName\": \"episode_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"title\",\n            \"columnName\": \"title\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"content\",\n            \"columnName\": \"content\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"url\",\n            \"columnName\": \"url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"headerImageUrl\",\n            \"columnName\": \"header_image_url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          },\n          {\n            \"fieldPath\": \"publishDate\",\n            \"columnName\": \"publish_date\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"type\",\n            \"columnName\": \"type\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_news_resources_episode_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"episode_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_episode_id` ON `${TABLE_NAME}` (`episode_id`)\"\n          }\n        ],\n        \"foreignKeys\": [\n          {\n            \"table\": \"episodes\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"episode_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"news_resources_topics\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` TEXT NOT NULL, `topic_id` TEXT NOT NULL, PRIMARY KEY(`news_resource_id`, `topic_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`topic_id`) REFERENCES `topics`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"newsResourceId\",\n            \"columnName\": \"news_resource_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"topicId\",\n            \"columnName\": \"topic_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"news_resource_id\",\n            \"topic_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_news_resources_topics_news_resource_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"news_resource_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_topics_news_resource_id` ON `${TABLE_NAME}` (`news_resource_id`)\"\n          },\n          {\n            \"name\": \"index_news_resources_topics_topic_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"topic_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_topics_topic_id` ON `${TABLE_NAME}` (`topic_id`)\"\n          }\n        ],\n        \"foreignKeys\": [\n          {\n            \"table\": \"news_resources\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"news_resource_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"topics\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"topic_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"topics\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL DEFAULT '', `url` TEXT NOT NULL DEFAULT '', `imageUrl` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"shortDescription\",\n            \"columnName\": \"shortDescription\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"longDescription\",\n            \"columnName\": \"longDescription\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"url\",\n            \"columnName\": \"url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"imageUrl\",\n            \"columnName\": \"imageUrl\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      }\n    ],\n    \"views\": [],\n    \"setupQueries\": [\n      \"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)\",\n      \"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd90e438da4b3b9cf552df536431dacb3')\"\n    ]\n  }\n}"
  },
  {
    "path": "core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/11.json",
    "content": "{\n  \"formatVersion\": 1,\n  \"database\": {\n    \"version\": 11,\n    \"identityHash\": \"2f83f889f6d8a96243f4ce387adbc604\",\n    \"entities\": [\n      {\n        \"tableName\": \"authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `image_url` TEXT NOT NULL, `twitter` TEXT NOT NULL DEFAULT '', `medium_page` TEXT NOT NULL DEFAULT '', `bio` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"imageUrl\",\n            \"columnName\": \"image_url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"twitter\",\n            \"columnName\": \"twitter\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"mediumPage\",\n            \"columnName\": \"medium_page\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"bio\",\n            \"columnName\": \"bio\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      },\n      {\n        \"tableName\": \"news_resources_authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` TEXT NOT NULL, `author_id` TEXT NOT NULL, PRIMARY KEY(`news_resource_id`, `author_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`author_id`) REFERENCES `authors`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"newsResourceId\",\n            \"columnName\": \"news_resource_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"authorId\",\n            \"columnName\": \"author_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"news_resource_id\",\n            \"author_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_news_resources_authors_news_resource_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"news_resource_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_authors_news_resource_id` ON `${TABLE_NAME}` (`news_resource_id`)\"\n          },\n          {\n            \"name\": \"index_news_resources_authors_author_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"author_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_authors_author_id` ON `${TABLE_NAME}` (`author_id`)\"\n          }\n        ],\n        \"foreignKeys\": [\n          {\n            \"table\": \"news_resources\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"news_resource_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"authors\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"author_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"news_resources\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `url` TEXT NOT NULL, `header_image_url` TEXT, `publish_date` INTEGER NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"title\",\n            \"columnName\": \"title\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"content\",\n            \"columnName\": \"content\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"url\",\n            \"columnName\": \"url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"headerImageUrl\",\n            \"columnName\": \"header_image_url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          },\n          {\n            \"fieldPath\": \"publishDate\",\n            \"columnName\": \"publish_date\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"type\",\n            \"columnName\": \"type\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      },\n      {\n        \"tableName\": \"news_resources_topics\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` TEXT NOT NULL, `topic_id` TEXT NOT NULL, PRIMARY KEY(`news_resource_id`, `topic_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`topic_id`) REFERENCES `topics`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"newsResourceId\",\n            \"columnName\": \"news_resource_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"topicId\",\n            \"columnName\": \"topic_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"news_resource_id\",\n            \"topic_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_news_resources_topics_news_resource_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"news_resource_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_topics_news_resource_id` ON `${TABLE_NAME}` (`news_resource_id`)\"\n          },\n          {\n            \"name\": \"index_news_resources_topics_topic_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"topic_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_topics_topic_id` ON `${TABLE_NAME}` (`topic_id`)\"\n          }\n        ],\n        \"foreignKeys\": [\n          {\n            \"table\": \"news_resources\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"news_resource_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"topics\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"topic_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"topics\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL DEFAULT '', `url` TEXT NOT NULL DEFAULT '', `imageUrl` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"shortDescription\",\n            \"columnName\": \"shortDescription\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"longDescription\",\n            \"columnName\": \"longDescription\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"url\",\n            \"columnName\": \"url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"imageUrl\",\n            \"columnName\": \"imageUrl\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      }\n    ],\n    \"views\": [],\n    \"setupQueries\": [\n      \"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)\",\n      \"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2f83f889f6d8a96243f4ce387adbc604')\"\n    ]\n  }\n}"
  },
  {
    "path": "core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/12.json",
    "content": "{\n  \"formatVersion\": 1,\n  \"database\": {\n    \"version\": 12,\n    \"identityHash\": \"f83b94b22ba0a0ce640922a3475e7c3e\",\n    \"entities\": [\n      {\n        \"tableName\": \"news_resources\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `url` TEXT NOT NULL, `header_image_url` TEXT, `publish_date` INTEGER NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"title\",\n            \"columnName\": \"title\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"content\",\n            \"columnName\": \"content\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"url\",\n            \"columnName\": \"url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"headerImageUrl\",\n            \"columnName\": \"header_image_url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          },\n          {\n            \"fieldPath\": \"publishDate\",\n            \"columnName\": \"publish_date\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"type\",\n            \"columnName\": \"type\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"autoGenerate\": false,\n          \"columnNames\": [\n            \"id\"\n          ]\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      },\n      {\n        \"tableName\": \"news_resources_topics\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` TEXT NOT NULL, `topic_id` TEXT NOT NULL, PRIMARY KEY(`news_resource_id`, `topic_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`topic_id`) REFERENCES `topics`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"newsResourceId\",\n            \"columnName\": \"news_resource_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"topicId\",\n            \"columnName\": \"topic_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"autoGenerate\": false,\n          \"columnNames\": [\n            \"news_resource_id\",\n            \"topic_id\"\n          ]\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_news_resources_topics_news_resource_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"news_resource_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_topics_news_resource_id` ON `${TABLE_NAME}` (`news_resource_id`)\"\n          },\n          {\n            \"name\": \"index_news_resources_topics_topic_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"topic_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_topics_topic_id` ON `${TABLE_NAME}` (`topic_id`)\"\n          }\n        ],\n        \"foreignKeys\": [\n          {\n            \"table\": \"news_resources\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"news_resource_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"topics\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"topic_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"topics\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL DEFAULT '', `url` TEXT NOT NULL DEFAULT '', `imageUrl` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"shortDescription\",\n            \"columnName\": \"shortDescription\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"longDescription\",\n            \"columnName\": \"longDescription\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"url\",\n            \"columnName\": \"url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"imageUrl\",\n            \"columnName\": \"imageUrl\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          }\n        ],\n        \"primaryKey\": {\n          \"autoGenerate\": false,\n          \"columnNames\": [\n            \"id\"\n          ]\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      }\n    ],\n    \"views\": [],\n    \"setupQueries\": [\n      \"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)\",\n      \"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f83b94b22ba0a0ce640922a3475e7c3e')\"\n    ]\n  }\n}"
  },
  {
    "path": "core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/13.json",
    "content": "{\n  \"formatVersion\": 1,\n  \"database\": {\n    \"version\": 13,\n    \"identityHash\": \"b6b299e53da623b16360975581ebfcfe\",\n    \"entities\": [\n      {\n        \"tableName\": \"news_resources\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `url` TEXT NOT NULL, `header_image_url` TEXT, `publish_date` INTEGER NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"title\",\n            \"columnName\": \"title\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"content\",\n            \"columnName\": \"content\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"url\",\n            \"columnName\": \"url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"headerImageUrl\",\n            \"columnName\": \"header_image_url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          },\n          {\n            \"fieldPath\": \"publishDate\",\n            \"columnName\": \"publish_date\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"type\",\n            \"columnName\": \"type\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"autoGenerate\": false,\n          \"columnNames\": [\n            \"id\"\n          ]\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      },\n      {\n        \"tableName\": \"news_resources_topics\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` TEXT NOT NULL, `topic_id` TEXT NOT NULL, PRIMARY KEY(`news_resource_id`, `topic_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`topic_id`) REFERENCES `topics`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"newsResourceId\",\n            \"columnName\": \"news_resource_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"topicId\",\n            \"columnName\": \"topic_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"autoGenerate\": false,\n          \"columnNames\": [\n            \"news_resource_id\",\n            \"topic_id\"\n          ]\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_news_resources_topics_news_resource_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"news_resource_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_topics_news_resource_id` ON `${TABLE_NAME}` (`news_resource_id`)\"\n          },\n          {\n            \"name\": \"index_news_resources_topics_topic_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"topic_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_topics_topic_id` ON `${TABLE_NAME}` (`topic_id`)\"\n          }\n        ],\n        \"foreignKeys\": [\n          {\n            \"table\": \"news_resources\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"news_resource_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"topics\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"topic_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"ftsVersion\": \"FTS4\",\n        \"ftsOptions\": {\n          \"tokenizer\": \"simple\",\n          \"tokenizerArgs\": [],\n          \"contentTable\": \"\",\n          \"languageIdColumnName\": \"\",\n          \"matchInfo\": \"FTS4\",\n          \"notIndexedColumns\": [],\n          \"prefixSizes\": [],\n          \"preferredOrder\": \"ASC\"\n        },\n        \"contentSyncTriggers\": [],\n        \"tableName\": \"newsResourcesFts\",\n        \"createSql\": \"CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`newsResourceId` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL)\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"newsResourceId\",\n            \"columnName\": \"newsResourceId\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"title\",\n            \"columnName\": \"title\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"content\",\n            \"columnName\": \"content\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"autoGenerate\": false,\n          \"columnNames\": []\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      },\n      {\n        \"tableName\": \"topics\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL DEFAULT '', `url` TEXT NOT NULL DEFAULT '', `imageUrl` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"shortDescription\",\n            \"columnName\": \"shortDescription\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"longDescription\",\n            \"columnName\": \"longDescription\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"url\",\n            \"columnName\": \"url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"imageUrl\",\n            \"columnName\": \"imageUrl\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          }\n        ],\n        \"primaryKey\": {\n          \"autoGenerate\": false,\n          \"columnNames\": [\n            \"id\"\n          ]\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      },\n      {\n        \"ftsVersion\": \"FTS4\",\n        \"ftsOptions\": {\n          \"tokenizer\": \"simple\",\n          \"tokenizerArgs\": [],\n          \"contentTable\": \"\",\n          \"languageIdColumnName\": \"\",\n          \"matchInfo\": \"FTS4\",\n          \"notIndexedColumns\": [],\n          \"prefixSizes\": [],\n          \"preferredOrder\": \"ASC\"\n        },\n        \"contentSyncTriggers\": [],\n        \"tableName\": \"topicsFts\",\n        \"createSql\": \"CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`topicId` TEXT NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL)\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"topicId\",\n            \"columnName\": \"topicId\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"shortDescription\",\n            \"columnName\": \"shortDescription\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"longDescription\",\n            \"columnName\": \"longDescription\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"autoGenerate\": false,\n          \"columnNames\": []\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      }\n    ],\n    \"views\": [],\n    \"setupQueries\": [\n      \"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)\",\n      \"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b6b299e53da623b16360975581ebfcfe')\"\n    ]\n  }\n}"
  },
  {
    "path": "core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/14.json",
    "content": "{\n  \"formatVersion\": 1,\n  \"database\": {\n    \"version\": 14,\n    \"identityHash\": \"51271b81bde7c7997d67fb23c8f31780\",\n    \"entities\": [\n      {\n        \"tableName\": \"news_resources\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `url` TEXT NOT NULL, `header_image_url` TEXT, `publish_date` INTEGER NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"title\",\n            \"columnName\": \"title\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"content\",\n            \"columnName\": \"content\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"url\",\n            \"columnName\": \"url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"headerImageUrl\",\n            \"columnName\": \"header_image_url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          },\n          {\n            \"fieldPath\": \"publishDate\",\n            \"columnName\": \"publish_date\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"type\",\n            \"columnName\": \"type\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"autoGenerate\": false,\n          \"columnNames\": [\n            \"id\"\n          ]\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      },\n      {\n        \"tableName\": \"news_resources_topics\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` TEXT NOT NULL, `topic_id` TEXT NOT NULL, PRIMARY KEY(`news_resource_id`, `topic_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`topic_id`) REFERENCES `topics`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"newsResourceId\",\n            \"columnName\": \"news_resource_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"topicId\",\n            \"columnName\": \"topic_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"autoGenerate\": false,\n          \"columnNames\": [\n            \"news_resource_id\",\n            \"topic_id\"\n          ]\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_news_resources_topics_news_resource_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"news_resource_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_topics_news_resource_id` ON `${TABLE_NAME}` (`news_resource_id`)\"\n          },\n          {\n            \"name\": \"index_news_resources_topics_topic_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"topic_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_topics_topic_id` ON `${TABLE_NAME}` (`topic_id`)\"\n          }\n        ],\n        \"foreignKeys\": [\n          {\n            \"table\": \"news_resources\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"news_resource_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"topics\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"topic_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"ftsVersion\": \"FTS4\",\n        \"ftsOptions\": {\n          \"tokenizer\": \"simple\",\n          \"tokenizerArgs\": [],\n          \"contentTable\": \"\",\n          \"languageIdColumnName\": \"\",\n          \"matchInfo\": \"FTS4\",\n          \"notIndexedColumns\": [],\n          \"prefixSizes\": [],\n          \"preferredOrder\": \"ASC\"\n        },\n        \"contentSyncTriggers\": [],\n        \"tableName\": \"newsResourcesFts\",\n        \"createSql\": \"CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`newsResourceId` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL)\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"newsResourceId\",\n            \"columnName\": \"newsResourceId\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"title\",\n            \"columnName\": \"title\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"content\",\n            \"columnName\": \"content\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"autoGenerate\": false,\n          \"columnNames\": []\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      },\n      {\n        \"tableName\": \"topics\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL DEFAULT '', `url` TEXT NOT NULL DEFAULT '', `imageUrl` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"shortDescription\",\n            \"columnName\": \"shortDescription\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"longDescription\",\n            \"columnName\": \"longDescription\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"url\",\n            \"columnName\": \"url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"imageUrl\",\n            \"columnName\": \"imageUrl\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          }\n        ],\n        \"primaryKey\": {\n          \"autoGenerate\": false,\n          \"columnNames\": [\n            \"id\"\n          ]\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      },\n      {\n        \"ftsVersion\": \"FTS4\",\n        \"ftsOptions\": {\n          \"tokenizer\": \"simple\",\n          \"tokenizerArgs\": [],\n          \"contentTable\": \"\",\n          \"languageIdColumnName\": \"\",\n          \"matchInfo\": \"FTS4\",\n          \"notIndexedColumns\": [],\n          \"prefixSizes\": [],\n          \"preferredOrder\": \"ASC\"\n        },\n        \"contentSyncTriggers\": [],\n        \"tableName\": \"topicsFts\",\n        \"createSql\": \"CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`topicId` TEXT NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL)\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"topicId\",\n            \"columnName\": \"topicId\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"shortDescription\",\n            \"columnName\": \"shortDescription\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"longDescription\",\n            \"columnName\": \"longDescription\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"autoGenerate\": false,\n          \"columnNames\": []\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      },\n      {\n        \"tableName\": \"recentSearchQueries\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, `queriedDate` INTEGER NOT NULL, PRIMARY KEY(`query`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"query\",\n            \"columnName\": \"query\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"queriedDate\",\n            \"columnName\": \"queriedDate\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"autoGenerate\": false,\n          \"columnNames\": [\n            \"query\"\n          ]\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      }\n    ],\n    \"views\": [],\n    \"setupQueries\": [\n      \"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)\",\n      \"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '51271b81bde7c7997d67fb23c8f31780')\"\n    ]\n  }\n}"
  },
  {
    "path": "core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/2.json",
    "content": "{\n  \"formatVersion\": 1,\n  \"database\": {\n    \"version\": 2,\n    \"identityHash\": \"5a10933609b5b8c099a04b971b4d12d9\",\n    \"entities\": [\n      {\n        \"tableName\": \"authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `image_url` TEXT NOT NULL, PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"imageUrl\",\n            \"columnName\": \"image_url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_authors_name\",\n            \"unique\": true,\n            \"columnNames\": [\n              \"name\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE UNIQUE INDEX IF NOT EXISTS `index_authors_name` ON `${TABLE_NAME}` (`name`)\"\n          }\n        ],\n        \"foreignKeys\": []\n      },\n      {\n        \"tableName\": \"episodes_authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episode_id` INTEGER NOT NULL, `author_id` INTEGER NOT NULL, PRIMARY KEY(`episode_id`, `author_id`), FOREIGN KEY(`episode_id`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`author_id`) REFERENCES `authors`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"episodeId\",\n            \"columnName\": \"episode_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"authorId\",\n            \"columnName\": \"author_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"episode_id\",\n            \"author_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": [\n          {\n            \"table\": \"episodes\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"episode_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"authors\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"author_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"episodes\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `publish_date` INTEGER NOT NULL, `alternate_video` TEXT, `alternate_audio` TEXT, PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"publishDate\",\n            \"columnName\": \"publish_date\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"alternateVideo\",\n            \"columnName\": \"alternate_video\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          },\n          {\n            \"fieldPath\": \"alternateAudio\",\n            \"columnName\": \"alternate_audio\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      },\n      {\n        \"tableName\": \"news_resources_authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` INTEGER NOT NULL, `author_id` INTEGER NOT NULL, PRIMARY KEY(`news_resource_id`, `author_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`author_id`) REFERENCES `authors`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"newsResourceId\",\n            \"columnName\": \"news_resource_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"authorId\",\n            \"columnName\": \"author_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"news_resource_id\",\n            \"author_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": [\n          {\n            \"table\": \"news_resources\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"news_resource_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"authors\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"author_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"news_resources\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `episode_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `url` TEXT NOT NULL, `header_image_url` TEXT, `publish_date` INTEGER NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`episode_id`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"episodeId\",\n            \"columnName\": \"episode_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"title\",\n            \"columnName\": \"title\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"content\",\n            \"columnName\": \"content\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"url\",\n            \"columnName\": \"url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"headerImageUrl\",\n            \"columnName\": \"header_image_url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          },\n          {\n            \"fieldPath\": \"publishDate\",\n            \"columnName\": \"publish_date\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"type\",\n            \"columnName\": \"type\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": [\n          {\n            \"table\": \"episodes\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"episode_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"news_resources_topics\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` INTEGER NOT NULL, `topic_id` INTEGER NOT NULL, PRIMARY KEY(`news_resource_id`, `topic_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`topic_id`) REFERENCES `topics`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"newsResourceId\",\n            \"columnName\": \"news_resource_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"topicId\",\n            \"columnName\": \"topic_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"news_resource_id\",\n            \"topic_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": [\n          {\n            \"table\": \"news_resources\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"news_resource_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"topics\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"topic_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"topics\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"description\",\n            \"columnName\": \"description\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_topics_name\",\n            \"unique\": true,\n            \"columnNames\": [\n              \"name\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE UNIQUE INDEX IF NOT EXISTS `index_topics_name` ON `${TABLE_NAME}` (`name`)\"\n          }\n        ],\n        \"foreignKeys\": []\n      }\n    ],\n    \"views\": [],\n    \"setupQueries\": [\n      \"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)\",\n      \"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5a10933609b5b8c099a04b971b4d12d9')\"\n    ]\n  }\n}"
  },
  {
    "path": "core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/3.json",
    "content": "{\n  \"formatVersion\": 1,\n  \"database\": {\n    \"version\": 3,\n    \"identityHash\": \"f593c030a1a8b5af8e13c6ac6a0926a9\",\n    \"entities\": [\n      {\n        \"tableName\": \"authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `image_url` TEXT NOT NULL, PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"imageUrl\",\n            \"columnName\": \"image_url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_authors_name\",\n            \"unique\": true,\n            \"columnNames\": [\n              \"name\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE UNIQUE INDEX IF NOT EXISTS `index_authors_name` ON `${TABLE_NAME}` (`name`)\"\n          }\n        ],\n        \"foreignKeys\": []\n      },\n      {\n        \"tableName\": \"episodes_authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episode_id` INTEGER NOT NULL, `author_id` INTEGER NOT NULL, PRIMARY KEY(`episode_id`, `author_id`), FOREIGN KEY(`episode_id`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`author_id`) REFERENCES `authors`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"episodeId\",\n            \"columnName\": \"episode_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"authorId\",\n            \"columnName\": \"author_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"episode_id\",\n            \"author_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": [\n          {\n            \"table\": \"episodes\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"episode_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"authors\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"author_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"episodes\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `publish_date` INTEGER NOT NULL, `alternate_video` TEXT, `alternate_audio` TEXT, PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"publishDate\",\n            \"columnName\": \"publish_date\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"alternateVideo\",\n            \"columnName\": \"alternate_video\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          },\n          {\n            \"fieldPath\": \"alternateAudio\",\n            \"columnName\": \"alternate_audio\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      },\n      {\n        \"tableName\": \"news_resources_authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` INTEGER NOT NULL, `author_id` INTEGER NOT NULL, PRIMARY KEY(`news_resource_id`, `author_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`author_id`) REFERENCES `authors`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"newsResourceId\",\n            \"columnName\": \"news_resource_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"authorId\",\n            \"columnName\": \"author_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"news_resource_id\",\n            \"author_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": [\n          {\n            \"table\": \"news_resources\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"news_resource_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"authors\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"author_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"news_resources\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `episode_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `url` TEXT NOT NULL, `header_image_url` TEXT, `publish_date` INTEGER NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`episode_id`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"episodeId\",\n            \"columnName\": \"episode_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"title\",\n            \"columnName\": \"title\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"content\",\n            \"columnName\": \"content\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"url\",\n            \"columnName\": \"url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"headerImageUrl\",\n            \"columnName\": \"header_image_url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          },\n          {\n            \"fieldPath\": \"publishDate\",\n            \"columnName\": \"publish_date\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"type\",\n            \"columnName\": \"type\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": [\n          {\n            \"table\": \"episodes\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"episode_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"news_resources_topics\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` INTEGER NOT NULL, `topic_id` INTEGER NOT NULL, PRIMARY KEY(`news_resource_id`, `topic_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`topic_id`) REFERENCES `topics`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"newsResourceId\",\n            \"columnName\": \"news_resource_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"topicId\",\n            \"columnName\": \"topic_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"news_resource_id\",\n            \"topic_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": [\n          {\n            \"table\": \"news_resources\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"news_resource_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"topics\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"topic_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"topics\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL DEFAULT '', `url` TEXT NOT NULL DEFAULT '', `imageUrl` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"shortDescription\",\n            \"columnName\": \"shortDescription\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"longDescription\",\n            \"columnName\": \"longDescription\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"url\",\n            \"columnName\": \"url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"imageUrl\",\n            \"columnName\": \"imageUrl\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_topics_name\",\n            \"unique\": true,\n            \"columnNames\": [\n              \"name\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE UNIQUE INDEX IF NOT EXISTS `index_topics_name` ON `${TABLE_NAME}` (`name`)\"\n          }\n        ],\n        \"foreignKeys\": []\n      }\n    ],\n    \"views\": [],\n    \"setupQueries\": [\n      \"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)\",\n      \"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f593c030a1a8b5af8e13c6ac6a0926a9')\"\n    ]\n  }\n}"
  },
  {
    "path": "core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/4.json",
    "content": "{\n  \"formatVersion\": 1,\n  \"database\": {\n    \"version\": 4,\n    \"identityHash\": \"f593c030a1a8b5af8e13c6ac6a0926a9\",\n    \"entities\": [\n      {\n        \"tableName\": \"authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `image_url` TEXT NOT NULL, PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"imageUrl\",\n            \"columnName\": \"image_url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_authors_name\",\n            \"unique\": true,\n            \"columnNames\": [\n              \"name\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE UNIQUE INDEX IF NOT EXISTS `index_authors_name` ON `${TABLE_NAME}` (`name`)\"\n          }\n        ],\n        \"foreignKeys\": []\n      },\n      {\n        \"tableName\": \"episodes_authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episode_id` INTEGER NOT NULL, `author_id` INTEGER NOT NULL, PRIMARY KEY(`episode_id`, `author_id`), FOREIGN KEY(`episode_id`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`author_id`) REFERENCES `authors`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"episodeId\",\n            \"columnName\": \"episode_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"authorId\",\n            \"columnName\": \"author_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"episode_id\",\n            \"author_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": [\n          {\n            \"table\": \"episodes\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"episode_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"authors\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"author_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"episodes\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `publish_date` INTEGER NOT NULL, `alternate_video` TEXT, `alternate_audio` TEXT, PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"publishDate\",\n            \"columnName\": \"publish_date\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"alternateVideo\",\n            \"columnName\": \"alternate_video\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          },\n          {\n            \"fieldPath\": \"alternateAudio\",\n            \"columnName\": \"alternate_audio\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      },\n      {\n        \"tableName\": \"news_resources_authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` INTEGER NOT NULL, `author_id` INTEGER NOT NULL, PRIMARY KEY(`news_resource_id`, `author_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`author_id`) REFERENCES `authors`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"newsResourceId\",\n            \"columnName\": \"news_resource_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"authorId\",\n            \"columnName\": \"author_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"news_resource_id\",\n            \"author_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": [\n          {\n            \"table\": \"news_resources\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"news_resource_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"authors\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"author_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"news_resources\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `episode_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `url` TEXT NOT NULL, `header_image_url` TEXT, `publish_date` INTEGER NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`episode_id`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"episodeId\",\n            \"columnName\": \"episode_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"title\",\n            \"columnName\": \"title\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"content\",\n            \"columnName\": \"content\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"url\",\n            \"columnName\": \"url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"headerImageUrl\",\n            \"columnName\": \"header_image_url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          },\n          {\n            \"fieldPath\": \"publishDate\",\n            \"columnName\": \"publish_date\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"type\",\n            \"columnName\": \"type\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": [\n          {\n            \"table\": \"episodes\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"episode_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"news_resources_topics\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` INTEGER NOT NULL, `topic_id` INTEGER NOT NULL, PRIMARY KEY(`news_resource_id`, `topic_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`topic_id`) REFERENCES `topics`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"newsResourceId\",\n            \"columnName\": \"news_resource_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"topicId\",\n            \"columnName\": \"topic_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"news_resource_id\",\n            \"topic_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": [\n          {\n            \"table\": \"news_resources\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"news_resource_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"topics\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"topic_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"topics\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL DEFAULT '', `url` TEXT NOT NULL DEFAULT '', `imageUrl` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"shortDescription\",\n            \"columnName\": \"shortDescription\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"longDescription\",\n            \"columnName\": \"longDescription\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"url\",\n            \"columnName\": \"url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"imageUrl\",\n            \"columnName\": \"imageUrl\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_topics_name\",\n            \"unique\": true,\n            \"columnNames\": [\n              \"name\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE UNIQUE INDEX IF NOT EXISTS `index_topics_name` ON `${TABLE_NAME}` (`name`)\"\n          }\n        ],\n        \"foreignKeys\": []\n      }\n    ],\n    \"views\": [],\n    \"setupQueries\": [\n      \"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)\",\n      \"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f593c030a1a8b5af8e13c6ac6a0926a9')\"\n    ]\n  }\n}"
  },
  {
    "path": "core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/5.json",
    "content": "{\n  \"formatVersion\": 1,\n  \"database\": {\n    \"version\": 5,\n    \"identityHash\": \"fdb65d28086c5a10d129b905ce940aa4\",\n    \"entities\": [\n      {\n        \"tableName\": \"authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `image_url` TEXT NOT NULL, `twitter` TEXT NOT NULL DEFAULT '', `medium_page` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"imageUrl\",\n            \"columnName\": \"image_url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"twitter\",\n            \"columnName\": \"twitter\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"mediumPage\",\n            \"columnName\": \"medium_page\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_authors_name\",\n            \"unique\": true,\n            \"columnNames\": [\n              \"name\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE UNIQUE INDEX IF NOT EXISTS `index_authors_name` ON `${TABLE_NAME}` (`name`)\"\n          }\n        ],\n        \"foreignKeys\": []\n      },\n      {\n        \"tableName\": \"episodes_authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episode_id` INTEGER NOT NULL, `author_id` INTEGER NOT NULL, PRIMARY KEY(`episode_id`, `author_id`), FOREIGN KEY(`episode_id`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`author_id`) REFERENCES `authors`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"episodeId\",\n            \"columnName\": \"episode_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"authorId\",\n            \"columnName\": \"author_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"episode_id\",\n            \"author_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": [\n          {\n            \"table\": \"episodes\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"episode_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"authors\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"author_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"episodes\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `publish_date` INTEGER NOT NULL, `alternate_video` TEXT, `alternate_audio` TEXT, PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"publishDate\",\n            \"columnName\": \"publish_date\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"alternateVideo\",\n            \"columnName\": \"alternate_video\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          },\n          {\n            \"fieldPath\": \"alternateAudio\",\n            \"columnName\": \"alternate_audio\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      },\n      {\n        \"tableName\": \"news_resources_authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` INTEGER NOT NULL, `author_id` INTEGER NOT NULL, PRIMARY KEY(`news_resource_id`, `author_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`author_id`) REFERENCES `authors`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"newsResourceId\",\n            \"columnName\": \"news_resource_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"authorId\",\n            \"columnName\": \"author_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"news_resource_id\",\n            \"author_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": [\n          {\n            \"table\": \"news_resources\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"news_resource_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"authors\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"author_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"news_resources\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `episode_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `url` TEXT NOT NULL, `header_image_url` TEXT, `publish_date` INTEGER NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`episode_id`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"episodeId\",\n            \"columnName\": \"episode_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"title\",\n            \"columnName\": \"title\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"content\",\n            \"columnName\": \"content\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"url\",\n            \"columnName\": \"url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"headerImageUrl\",\n            \"columnName\": \"header_image_url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          },\n          {\n            \"fieldPath\": \"publishDate\",\n            \"columnName\": \"publish_date\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"type\",\n            \"columnName\": \"type\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": [\n          {\n            \"table\": \"episodes\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"episode_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"news_resources_topics\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` INTEGER NOT NULL, `topic_id` INTEGER NOT NULL, PRIMARY KEY(`news_resource_id`, `topic_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`topic_id`) REFERENCES `topics`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"newsResourceId\",\n            \"columnName\": \"news_resource_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"topicId\",\n            \"columnName\": \"topic_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"news_resource_id\",\n            \"topic_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": [\n          {\n            \"table\": \"news_resources\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"news_resource_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"topics\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"topic_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"topics\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL DEFAULT '', `url` TEXT NOT NULL DEFAULT '', `imageUrl` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"shortDescription\",\n            \"columnName\": \"shortDescription\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"longDescription\",\n            \"columnName\": \"longDescription\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"url\",\n            \"columnName\": \"url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"imageUrl\",\n            \"columnName\": \"imageUrl\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_topics_name\",\n            \"unique\": true,\n            \"columnNames\": [\n              \"name\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE UNIQUE INDEX IF NOT EXISTS `index_topics_name` ON `${TABLE_NAME}` (`name`)\"\n          }\n        ],\n        \"foreignKeys\": []\n      }\n    ],\n    \"views\": [],\n    \"setupQueries\": [\n      \"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)\",\n      \"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fdb65d28086c5a10d129b905ce940aa4')\"\n    ]\n  }\n}"
  },
  {
    "path": "core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/6.json",
    "content": "{\n  \"formatVersion\": 1,\n  \"database\": {\n    \"version\": 6,\n    \"identityHash\": \"6bd08a2b063057f8fa1a1cfe31f7aca4\",\n    \"entities\": [\n      {\n        \"tableName\": \"authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `image_url` TEXT NOT NULL, `twitter` TEXT NOT NULL DEFAULT '', `medium_page` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"imageUrl\",\n            \"columnName\": \"image_url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"twitter\",\n            \"columnName\": \"twitter\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"mediumPage\",\n            \"columnName\": \"medium_page\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      },\n      {\n        \"tableName\": \"episodes_authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episode_id` INTEGER NOT NULL, `author_id` INTEGER NOT NULL, PRIMARY KEY(`episode_id`, `author_id`), FOREIGN KEY(`episode_id`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`author_id`) REFERENCES `authors`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"episodeId\",\n            \"columnName\": \"episode_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"authorId\",\n            \"columnName\": \"author_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"episode_id\",\n            \"author_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_episodes_authors_episode_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"episode_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_episodes_authors_episode_id` ON `${TABLE_NAME}` (`episode_id`)\"\n          },\n          {\n            \"name\": \"index_episodes_authors_author_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"author_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_episodes_authors_author_id` ON `${TABLE_NAME}` (`author_id`)\"\n          }\n        ],\n        \"foreignKeys\": [\n          {\n            \"table\": \"episodes\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"episode_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"authors\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"author_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"episodes\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `publish_date` INTEGER NOT NULL, `alternate_video` TEXT, `alternate_audio` TEXT, PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"publishDate\",\n            \"columnName\": \"publish_date\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"alternateVideo\",\n            \"columnName\": \"alternate_video\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          },\n          {\n            \"fieldPath\": \"alternateAudio\",\n            \"columnName\": \"alternate_audio\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      },\n      {\n        \"tableName\": \"news_resources_authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` INTEGER NOT NULL, `author_id` INTEGER NOT NULL, PRIMARY KEY(`news_resource_id`, `author_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`author_id`) REFERENCES `authors`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"newsResourceId\",\n            \"columnName\": \"news_resource_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"authorId\",\n            \"columnName\": \"author_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"news_resource_id\",\n            \"author_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_news_resources_authors_news_resource_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"news_resource_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_authors_news_resource_id` ON `${TABLE_NAME}` (`news_resource_id`)\"\n          },\n          {\n            \"name\": \"index_news_resources_authors_author_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"author_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_authors_author_id` ON `${TABLE_NAME}` (`author_id`)\"\n          }\n        ],\n        \"foreignKeys\": [\n          {\n            \"table\": \"news_resources\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"news_resource_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"authors\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"author_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"news_resources\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `episode_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `url` TEXT NOT NULL, `header_image_url` TEXT, `publish_date` INTEGER NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`episode_id`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"episodeId\",\n            \"columnName\": \"episode_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"title\",\n            \"columnName\": \"title\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"content\",\n            \"columnName\": \"content\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"url\",\n            \"columnName\": \"url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"headerImageUrl\",\n            \"columnName\": \"header_image_url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          },\n          {\n            \"fieldPath\": \"publishDate\",\n            \"columnName\": \"publish_date\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"type\",\n            \"columnName\": \"type\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": [\n          {\n            \"table\": \"episodes\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"episode_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"news_resources_topics\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` INTEGER NOT NULL, `topic_id` INTEGER NOT NULL, PRIMARY KEY(`news_resource_id`, `topic_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`topic_id`) REFERENCES `topics`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"newsResourceId\",\n            \"columnName\": \"news_resource_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"topicId\",\n            \"columnName\": \"topic_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"news_resource_id\",\n            \"topic_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_news_resources_topics_news_resource_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"news_resource_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_topics_news_resource_id` ON `${TABLE_NAME}` (`news_resource_id`)\"\n          },\n          {\n            \"name\": \"index_news_resources_topics_topic_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"topic_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_topics_topic_id` ON `${TABLE_NAME}` (`topic_id`)\"\n          }\n        ],\n        \"foreignKeys\": [\n          {\n            \"table\": \"news_resources\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"news_resource_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"topics\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"topic_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"topics\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL DEFAULT '', `url` TEXT NOT NULL DEFAULT '', `imageUrl` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"shortDescription\",\n            \"columnName\": \"shortDescription\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"longDescription\",\n            \"columnName\": \"longDescription\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"url\",\n            \"columnName\": \"url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"imageUrl\",\n            \"columnName\": \"imageUrl\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_topics_name\",\n            \"unique\": true,\n            \"columnNames\": [\n              \"name\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE UNIQUE INDEX IF NOT EXISTS `index_topics_name` ON `${TABLE_NAME}` (`name`)\"\n          }\n        ],\n        \"foreignKeys\": []\n      }\n    ],\n    \"views\": [],\n    \"setupQueries\": [\n      \"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)\",\n      \"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6bd08a2b063057f8fa1a1cfe31f7aca4')\"\n    ]\n  }\n}"
  },
  {
    "path": "core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/7.json",
    "content": "{\n  \"formatVersion\": 1,\n  \"database\": {\n    \"version\": 7,\n    \"identityHash\": \"3949bb3fd61bb4eba902a293b45f44e4\",\n    \"entities\": [\n      {\n        \"tableName\": \"authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `image_url` TEXT NOT NULL, `twitter` TEXT NOT NULL DEFAULT '', `medium_page` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"imageUrl\",\n            \"columnName\": \"image_url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"twitter\",\n            \"columnName\": \"twitter\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"mediumPage\",\n            \"columnName\": \"medium_page\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      },\n      {\n        \"tableName\": \"episodes_authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episode_id` INTEGER NOT NULL, `author_id` INTEGER NOT NULL, PRIMARY KEY(`episode_id`, `author_id`), FOREIGN KEY(`episode_id`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`author_id`) REFERENCES `authors`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"episodeId\",\n            \"columnName\": \"episode_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"authorId\",\n            \"columnName\": \"author_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"episode_id\",\n            \"author_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_episodes_authors_episode_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"episode_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_episodes_authors_episode_id` ON `${TABLE_NAME}` (`episode_id`)\"\n          },\n          {\n            \"name\": \"index_episodes_authors_author_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"author_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_episodes_authors_author_id` ON `${TABLE_NAME}` (`author_id`)\"\n          }\n        ],\n        \"foreignKeys\": [\n          {\n            \"table\": \"episodes\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"episode_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"authors\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"author_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"episodes\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `publish_date` INTEGER NOT NULL, `alternate_video` TEXT, `alternate_audio` TEXT, PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"publishDate\",\n            \"columnName\": \"publish_date\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"alternateVideo\",\n            \"columnName\": \"alternate_video\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          },\n          {\n            \"fieldPath\": \"alternateAudio\",\n            \"columnName\": \"alternate_audio\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      },\n      {\n        \"tableName\": \"news_resources_authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` INTEGER NOT NULL, `author_id` INTEGER NOT NULL, PRIMARY KEY(`news_resource_id`, `author_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`author_id`) REFERENCES `authors`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"newsResourceId\",\n            \"columnName\": \"news_resource_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"authorId\",\n            \"columnName\": \"author_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"news_resource_id\",\n            \"author_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_news_resources_authors_news_resource_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"news_resource_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_authors_news_resource_id` ON `${TABLE_NAME}` (`news_resource_id`)\"\n          },\n          {\n            \"name\": \"index_news_resources_authors_author_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"author_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_authors_author_id` ON `${TABLE_NAME}` (`author_id`)\"\n          }\n        ],\n        \"foreignKeys\": [\n          {\n            \"table\": \"news_resources\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"news_resource_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"authors\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"author_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"news_resources\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `episode_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `url` TEXT NOT NULL, `header_image_url` TEXT, `publish_date` INTEGER NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`episode_id`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"episodeId\",\n            \"columnName\": \"episode_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"title\",\n            \"columnName\": \"title\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"content\",\n            \"columnName\": \"content\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"url\",\n            \"columnName\": \"url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"headerImageUrl\",\n            \"columnName\": \"header_image_url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          },\n          {\n            \"fieldPath\": \"publishDate\",\n            \"columnName\": \"publish_date\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"type\",\n            \"columnName\": \"type\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": [\n          {\n            \"table\": \"episodes\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"episode_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"news_resources_topics\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` INTEGER NOT NULL, `topic_id` INTEGER NOT NULL, PRIMARY KEY(`news_resource_id`, `topic_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`topic_id`) REFERENCES `topics`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"newsResourceId\",\n            \"columnName\": \"news_resource_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"topicId\",\n            \"columnName\": \"topic_id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"news_resource_id\",\n            \"topic_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_news_resources_topics_news_resource_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"news_resource_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_topics_news_resource_id` ON `${TABLE_NAME}` (`news_resource_id`)\"\n          },\n          {\n            \"name\": \"index_news_resources_topics_topic_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"topic_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_topics_topic_id` ON `${TABLE_NAME}` (`topic_id`)\"\n          }\n        ],\n        \"foreignKeys\": [\n          {\n            \"table\": \"news_resources\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"news_resource_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"topics\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"topic_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"topics\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL DEFAULT '', `url` TEXT NOT NULL DEFAULT '', `imageUrl` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"shortDescription\",\n            \"columnName\": \"shortDescription\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"longDescription\",\n            \"columnName\": \"longDescription\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"url\",\n            \"columnName\": \"url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"imageUrl\",\n            \"columnName\": \"imageUrl\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      }\n    ],\n    \"views\": [],\n    \"setupQueries\": [\n      \"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)\",\n      \"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3949bb3fd61bb4eba902a293b45f44e4')\"\n    ]\n  }\n}"
  },
  {
    "path": "core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/8.json",
    "content": "{\n  \"formatVersion\": 1,\n  \"database\": {\n    \"version\": 8,\n    \"identityHash\": \"28ce6650a00ef6281cef0045f5539112\",\n    \"entities\": [\n      {\n        \"tableName\": \"authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `image_url` TEXT NOT NULL, `twitter` TEXT NOT NULL DEFAULT '', `medium_page` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"imageUrl\",\n            \"columnName\": \"image_url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"twitter\",\n            \"columnName\": \"twitter\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"mediumPage\",\n            \"columnName\": \"medium_page\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      },\n      {\n        \"tableName\": \"episodes_authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episode_id` TEXT NOT NULL, `author_id` TEXT NOT NULL, PRIMARY KEY(`episode_id`, `author_id`), FOREIGN KEY(`episode_id`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`author_id`) REFERENCES `authors`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"episodeId\",\n            \"columnName\": \"episode_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"authorId\",\n            \"columnName\": \"author_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"episode_id\",\n            \"author_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_episodes_authors_episode_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"episode_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_episodes_authors_episode_id` ON `${TABLE_NAME}` (`episode_id`)\"\n          },\n          {\n            \"name\": \"index_episodes_authors_author_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"author_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_episodes_authors_author_id` ON `${TABLE_NAME}` (`author_id`)\"\n          }\n        ],\n        \"foreignKeys\": [\n          {\n            \"table\": \"episodes\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"episode_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"authors\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"author_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"episodes\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `publish_date` INTEGER NOT NULL, `alternate_video` TEXT, `alternate_audio` TEXT, PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"publishDate\",\n            \"columnName\": \"publish_date\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"alternateVideo\",\n            \"columnName\": \"alternate_video\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          },\n          {\n            \"fieldPath\": \"alternateAudio\",\n            \"columnName\": \"alternate_audio\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      },\n      {\n        \"tableName\": \"news_resources_authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` TEXT NOT NULL, `author_id` TEXT NOT NULL, PRIMARY KEY(`news_resource_id`, `author_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`author_id`) REFERENCES `authors`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"newsResourceId\",\n            \"columnName\": \"news_resource_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"authorId\",\n            \"columnName\": \"author_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"news_resource_id\",\n            \"author_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_news_resources_authors_news_resource_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"news_resource_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_authors_news_resource_id` ON `${TABLE_NAME}` (`news_resource_id`)\"\n          },\n          {\n            \"name\": \"index_news_resources_authors_author_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"author_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_authors_author_id` ON `${TABLE_NAME}` (`author_id`)\"\n          }\n        ],\n        \"foreignKeys\": [\n          {\n            \"table\": \"news_resources\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"news_resource_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"authors\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"author_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"news_resources\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `episode_id` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `url` TEXT NOT NULL, `header_image_url` TEXT, `publish_date` INTEGER NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`episode_id`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"episodeId\",\n            \"columnName\": \"episode_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"title\",\n            \"columnName\": \"title\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"content\",\n            \"columnName\": \"content\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"url\",\n            \"columnName\": \"url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"headerImageUrl\",\n            \"columnName\": \"header_image_url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          },\n          {\n            \"fieldPath\": \"publishDate\",\n            \"columnName\": \"publish_date\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"type\",\n            \"columnName\": \"type\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": [\n          {\n            \"table\": \"episodes\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"episode_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"news_resources_topics\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` TEXT NOT NULL, `topic_id` TEXT NOT NULL, PRIMARY KEY(`news_resource_id`, `topic_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`topic_id`) REFERENCES `topics`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"newsResourceId\",\n            \"columnName\": \"news_resource_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"topicId\",\n            \"columnName\": \"topic_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"news_resource_id\",\n            \"topic_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_news_resources_topics_news_resource_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"news_resource_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_topics_news_resource_id` ON `${TABLE_NAME}` (`news_resource_id`)\"\n          },\n          {\n            \"name\": \"index_news_resources_topics_topic_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"topic_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_topics_topic_id` ON `${TABLE_NAME}` (`topic_id`)\"\n          }\n        ],\n        \"foreignKeys\": [\n          {\n            \"table\": \"news_resources\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"news_resource_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"topics\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"topic_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"topics\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL DEFAULT '', `url` TEXT NOT NULL DEFAULT '', `imageUrl` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"shortDescription\",\n            \"columnName\": \"shortDescription\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"longDescription\",\n            \"columnName\": \"longDescription\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"url\",\n            \"columnName\": \"url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"imageUrl\",\n            \"columnName\": \"imageUrl\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      }\n    ],\n    \"views\": [],\n    \"setupQueries\": [\n      \"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)\",\n      \"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '28ce6650a00ef6281cef0045f5539112')\"\n    ]\n  }\n}"
  },
  {
    "path": "core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/9.json",
    "content": "{\n  \"formatVersion\": 1,\n  \"database\": {\n    \"version\": 9,\n    \"identityHash\": \"9294402ce9ec4e6bf008b0a1f5383ecd\",\n    \"entities\": [\n      {\n        \"tableName\": \"authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `image_url` TEXT NOT NULL, `twitter` TEXT NOT NULL DEFAULT '', `medium_page` TEXT NOT NULL DEFAULT '', `bio` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"imageUrl\",\n            \"columnName\": \"image_url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"twitter\",\n            \"columnName\": \"twitter\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"mediumPage\",\n            \"columnName\": \"medium_page\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"bio\",\n            \"columnName\": \"bio\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      },\n      {\n        \"tableName\": \"episodes_authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episode_id` TEXT NOT NULL, `author_id` TEXT NOT NULL, PRIMARY KEY(`episode_id`, `author_id`), FOREIGN KEY(`episode_id`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`author_id`) REFERENCES `authors`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"episodeId\",\n            \"columnName\": \"episode_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"authorId\",\n            \"columnName\": \"author_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"episode_id\",\n            \"author_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_episodes_authors_episode_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"episode_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_episodes_authors_episode_id` ON `${TABLE_NAME}` (`episode_id`)\"\n          },\n          {\n            \"name\": \"index_episodes_authors_author_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"author_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_episodes_authors_author_id` ON `${TABLE_NAME}` (`author_id`)\"\n          }\n        ],\n        \"foreignKeys\": [\n          {\n            \"table\": \"episodes\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"episode_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"authors\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"author_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"episodes\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `publish_date` INTEGER NOT NULL, `alternate_video` TEXT, `alternate_audio` TEXT, PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"publishDate\",\n            \"columnName\": \"publish_date\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"alternateVideo\",\n            \"columnName\": \"alternate_video\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          },\n          {\n            \"fieldPath\": \"alternateAudio\",\n            \"columnName\": \"alternate_audio\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      },\n      {\n        \"tableName\": \"news_resources_authors\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` TEXT NOT NULL, `author_id` TEXT NOT NULL, PRIMARY KEY(`news_resource_id`, `author_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`author_id`) REFERENCES `authors`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"newsResourceId\",\n            \"columnName\": \"news_resource_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"authorId\",\n            \"columnName\": \"author_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"news_resource_id\",\n            \"author_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_news_resources_authors_news_resource_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"news_resource_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_authors_news_resource_id` ON `${TABLE_NAME}` (`news_resource_id`)\"\n          },\n          {\n            \"name\": \"index_news_resources_authors_author_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"author_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_authors_author_id` ON `${TABLE_NAME}` (`author_id`)\"\n          }\n        ],\n        \"foreignKeys\": [\n          {\n            \"table\": \"news_resources\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"news_resource_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"authors\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"author_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"news_resources\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `episode_id` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `url` TEXT NOT NULL, `header_image_url` TEXT, `publish_date` INTEGER NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`episode_id`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"episodeId\",\n            \"columnName\": \"episode_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"title\",\n            \"columnName\": \"title\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"content\",\n            \"columnName\": \"content\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"url\",\n            \"columnName\": \"url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"headerImageUrl\",\n            \"columnName\": \"header_image_url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": false\n          },\n          {\n            \"fieldPath\": \"publishDate\",\n            \"columnName\": \"publish_date\",\n            \"affinity\": \"INTEGER\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"type\",\n            \"columnName\": \"type\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": [\n          {\n            \"table\": \"episodes\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"episode_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"news_resources_topics\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` TEXT NOT NULL, `topic_id` TEXT NOT NULL, PRIMARY KEY(`news_resource_id`, `topic_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`topic_id`) REFERENCES `topics`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"newsResourceId\",\n            \"columnName\": \"news_resource_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"topicId\",\n            \"columnName\": \"topic_id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"news_resource_id\",\n            \"topic_id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [\n          {\n            \"name\": \"index_news_resources_topics_news_resource_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"news_resource_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_topics_news_resource_id` ON `${TABLE_NAME}` (`news_resource_id`)\"\n          },\n          {\n            \"name\": \"index_news_resources_topics_topic_id\",\n            \"unique\": false,\n            \"columnNames\": [\n              \"topic_id\"\n            ],\n            \"orders\": [],\n            \"createSql\": \"CREATE INDEX IF NOT EXISTS `index_news_resources_topics_topic_id` ON `${TABLE_NAME}` (`topic_id`)\"\n          }\n        ],\n        \"foreignKeys\": [\n          {\n            \"table\": \"news_resources\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"news_resource_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          },\n          {\n            \"table\": \"topics\",\n            \"onDelete\": \"CASCADE\",\n            \"onUpdate\": \"NO ACTION\",\n            \"columns\": [\n              \"topic_id\"\n            ],\n            \"referencedColumns\": [\n              \"id\"\n            ]\n          }\n        ]\n      },\n      {\n        \"tableName\": \"topics\",\n        \"createSql\": \"CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL DEFAULT '', `url` TEXT NOT NULL DEFAULT '', `imageUrl` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))\",\n        \"fields\": [\n          {\n            \"fieldPath\": \"id\",\n            \"columnName\": \"id\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"name\",\n            \"columnName\": \"name\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"shortDescription\",\n            \"columnName\": \"shortDescription\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true\n          },\n          {\n            \"fieldPath\": \"longDescription\",\n            \"columnName\": \"longDescription\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"url\",\n            \"columnName\": \"url\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          },\n          {\n            \"fieldPath\": \"imageUrl\",\n            \"columnName\": \"imageUrl\",\n            \"affinity\": \"TEXT\",\n            \"notNull\": true,\n            \"defaultValue\": \"''\"\n          }\n        ],\n        \"primaryKey\": {\n          \"columnNames\": [\n            \"id\"\n          ],\n          \"autoGenerate\": false\n        },\n        \"indices\": [],\n        \"foreignKeys\": []\n      }\n    ],\n    \"views\": [],\n    \"setupQueries\": [\n      \"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)\",\n      \"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9294402ce9ec4e6bf008b0a1f5383ecd')\"\n    ]\n  }\n}"
  },
  {
    "path": "core/database/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/core/database/dao/DatabaseTest.kt",
    "content": "/*\n * Copyright 2025 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.database.dao\n\nimport android.content.Context\nimport androidx.room.Room\nimport androidx.test.core.app.ApplicationProvider\nimport com.google.samples.apps.nowinandroid.core.database.NiaDatabase\nimport org.junit.After\nimport org.junit.Before\n\ninternal abstract class DatabaseTest {\n\n    private lateinit var db: NiaDatabase\n    protected lateinit var newsResourceDao: NewsResourceDao\n    protected lateinit var topicDao: TopicDao\n\n    @Before\n    fun setup() {\n        db = run {\n            val context = ApplicationProvider.getApplicationContext<Context>()\n            Room.inMemoryDatabaseBuilder(\n                context,\n                NiaDatabase::class.java,\n            ).build()\n        }\n        newsResourceDao = db.newsResourceDao()\n        topicDao = db.topicDao()\n    }\n\n    @After\n    fun teardown() = db.close()\n}\n"
  },
  {
    "path": "core/database/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDaoTest.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.database.dao\n\nimport com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity\nimport com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef\nimport com.google.samples.apps.nowinandroid.core.database.model.TopicEntity\nimport com.google.samples.apps.nowinandroid.core.database.model.asExternalModel\nimport kotlinx.coroutines.flow.first\nimport kotlinx.coroutines.test.runTest\nimport kotlinx.datetime.Instant\nimport org.junit.Test\nimport kotlin.test.assertEquals\n\ninternal class NewsResourceDaoTest : DatabaseTest() {\n\n    @Test\n    fun getNewsResources_allEntries_areOrderedByPublishDateDesc() = runTest {\n        val newsResourceEntities = listOf(\n            testNewsResource(\n                id = \"0\",\n                millisSinceEpoch = 0,\n            ),\n            testNewsResource(\n                id = \"1\",\n                millisSinceEpoch = 3,\n            ),\n            testNewsResource(\n                id = \"2\",\n                millisSinceEpoch = 1,\n            ),\n            testNewsResource(\n                id = \"3\",\n                millisSinceEpoch = 2,\n            ),\n        )\n        newsResourceDao.upsertNewsResources(\n            newsResourceEntities,\n        )\n\n        val savedNewsResourceEntities = newsResourceDao.getNewsResources()\n            .first()\n\n        assertEquals(\n            listOf(3L, 2L, 1L, 0L),\n            savedNewsResourceEntities.map {\n                it.asExternalModel().publishDate.toEpochMilliseconds()\n            },\n        )\n    }\n\n    @Test\n    fun getNewsResources_filteredById_areOrderedByDescendingPublishDate() = runTest {\n        val newsResourceEntities = listOf(\n            testNewsResource(\n                id = \"0\",\n                millisSinceEpoch = 0,\n            ),\n            testNewsResource(\n                id = \"1\",\n                millisSinceEpoch = 3,\n            ),\n            testNewsResource(\n                id = \"2\",\n                millisSinceEpoch = 1,\n            ),\n            testNewsResource(\n                id = \"3\",\n                millisSinceEpoch = 2,\n            ),\n        )\n        newsResourceDao.upsertNewsResources(\n            newsResourceEntities,\n        )\n\n        val savedNewsResourceEntities = newsResourceDao.getNewsResources(\n            useFilterNewsIds = true,\n            filterNewsIds = setOf(\"3\", \"0\"),\n        )\n            .first()\n\n        assertEquals(\n            listOf(\"3\", \"0\"),\n            savedNewsResourceEntities.map {\n                it.entity.id\n            },\n        )\n    }\n\n    @Test\n    fun getNewsResources_filteredByTopicId_areOrderedByDescendingPublishDate() = runTest {\n        val topicEntities = listOf(\n            testTopicEntity(\n                id = \"1\",\n                name = \"1\",\n            ),\n            testTopicEntity(\n                id = \"2\",\n                name = \"2\",\n            ),\n        )\n        val newsResourceEntities = listOf(\n            testNewsResource(\n                id = \"0\",\n                millisSinceEpoch = 0,\n            ),\n            testNewsResource(\n                id = \"1\",\n                millisSinceEpoch = 3,\n            ),\n            testNewsResource(\n                id = \"2\",\n                millisSinceEpoch = 1,\n            ),\n            testNewsResource(\n                id = \"3\",\n                millisSinceEpoch = 2,\n            ),\n        )\n        val newsResourceTopicCrossRefEntities = topicEntities.mapIndexed { index, topicEntity ->\n            NewsResourceTopicCrossRef(\n                newsResourceId = index.toString(),\n                topicId = topicEntity.id,\n            )\n        }\n\n        topicDao.insertOrIgnoreTopics(\n            topicEntities = topicEntities,\n        )\n        newsResourceDao.upsertNewsResources(\n            newsResourceEntities,\n        )\n        newsResourceDao.insertOrIgnoreTopicCrossRefEntities(\n            newsResourceTopicCrossRefEntities,\n        )\n\n        val filteredNewsResources = newsResourceDao.getNewsResources(\n            useFilterTopicIds = true,\n            filterTopicIds = topicEntities\n                .map(TopicEntity::id)\n                .toSet(),\n        ).first()\n\n        assertEquals(\n            listOf(\"1\", \"0\"),\n            filteredNewsResources.map { it.entity.id },\n        )\n    }\n\n    @Test\n    fun getNewsResources_filteredByIdAndTopicId_areOrderedByDescendingPublishDate() = runTest {\n        val topicEntities = listOf(\n            testTopicEntity(\n                id = \"1\",\n                name = \"1\",\n            ),\n            testTopicEntity(\n                id = \"2\",\n                name = \"2\",\n            ),\n        )\n        val newsResourceEntities = listOf(\n            testNewsResource(\n                id = \"0\",\n                millisSinceEpoch = 0,\n            ),\n            testNewsResource(\n                id = \"1\",\n                millisSinceEpoch = 3,\n            ),\n            testNewsResource(\n                id = \"2\",\n                millisSinceEpoch = 1,\n            ),\n            testNewsResource(\n                id = \"3\",\n                millisSinceEpoch = 2,\n            ),\n        )\n        val newsResourceTopicCrossRefEntities = topicEntities.mapIndexed { index, topicEntity ->\n            NewsResourceTopicCrossRef(\n                newsResourceId = index.toString(),\n                topicId = topicEntity.id,\n            )\n        }\n\n        topicDao.insertOrIgnoreTopics(\n            topicEntities = topicEntities,\n        )\n        newsResourceDao.upsertNewsResources(\n            newsResourceEntities,\n        )\n        newsResourceDao.insertOrIgnoreTopicCrossRefEntities(\n            newsResourceTopicCrossRefEntities,\n        )\n\n        val filteredNewsResources = newsResourceDao.getNewsResources(\n            useFilterTopicIds = true,\n            filterTopicIds = topicEntities\n                .map(TopicEntity::id)\n                .toSet(),\n            useFilterNewsIds = true,\n            filterNewsIds = setOf(\"1\"),\n        ).first()\n\n        assertEquals(\n            listOf(\"1\"),\n            filteredNewsResources.map { it.entity.id },\n        )\n    }\n\n    @Test\n    fun deleteNewsResources_byId() =\n        runTest {\n            val newsResourceEntities = listOf(\n                testNewsResource(\n                    id = \"0\",\n                    millisSinceEpoch = 0,\n                ),\n                testNewsResource(\n                    id = \"1\",\n                    millisSinceEpoch = 3,\n                ),\n                testNewsResource(\n                    id = \"2\",\n                    millisSinceEpoch = 1,\n                ),\n                testNewsResource(\n                    id = \"3\",\n                    millisSinceEpoch = 2,\n                ),\n            )\n            newsResourceDao.upsertNewsResources(newsResourceEntities)\n\n            val (toDelete, toKeep) = newsResourceEntities.partition { it.id.toInt() % 2 == 0 }\n\n            newsResourceDao.deleteNewsResources(\n                toDelete.map(NewsResourceEntity::id),\n            )\n\n            assertEquals(\n                toKeep.map(NewsResourceEntity::id)\n                    .toSet(),\n                newsResourceDao.getNewsResources().first()\n                    .map { it.entity.id }\n                    .toSet(),\n            )\n        }\n}\n\nprivate fun testTopicEntity(\n    id: String = \"0\",\n    name: String,\n) = TopicEntity(\n    id = id,\n    name = name,\n    shortDescription = \"\",\n    longDescription = \"\",\n    url = \"\",\n    imageUrl = \"\",\n)\n\nprivate fun testNewsResource(\n    id: String = \"0\",\n    millisSinceEpoch: Long = 0,\n) = NewsResourceEntity(\n    id = id,\n    title = \"\",\n    content = \"\",\n    url = \"\",\n    headerImageUrl = \"\",\n    publishDate = Instant.fromEpochMilliseconds(millisSinceEpoch),\n    type = \"Article 📚\",\n)\n"
  },
  {
    "path": "core/database/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/core/database/dao/TopicDaoTest.kt",
    "content": "/*\n * Copyright 2024 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.database.dao\n\nimport com.google.samples.apps.nowinandroid.core.database.model.TopicEntity\nimport kotlinx.coroutines.flow.first\nimport kotlinx.coroutines.test.runTest\nimport org.junit.Test\nimport kotlin.test.assertEquals\n\ninternal class TopicDaoTest : DatabaseTest() {\n\n    @Test\n    fun getTopics() = runTest {\n        insertTopics()\n\n        val savedTopics = topicDao.getTopicEntities().first()\n\n        assertEquals(\n            listOf(\"1\", \"2\", \"3\"),\n            savedTopics.map { it.id },\n        )\n    }\n\n    @Test\n    fun getTopic() = runTest {\n        insertTopics()\n\n        val savedTopicEntity = topicDao.getTopicEntity(\"2\").first()\n\n        assertEquals(\"performance\", savedTopicEntity.name)\n    }\n\n    @Test\n    fun getTopics_oneOff() = runTest {\n        insertTopics()\n\n        val savedTopics = topicDao.getOneOffTopicEntities()\n\n        assertEquals(\n            listOf(\"1\", \"2\", \"3\"),\n            savedTopics.map { it.id },\n        )\n    }\n\n    @Test\n    fun getTopics_byId() = runTest {\n        insertTopics()\n\n        val savedTopics = topicDao.getTopicEntities(setOf(\"1\", \"2\"))\n            .first()\n\n        assertEquals(listOf(\"compose\", \"performance\"), savedTopics.map { it.name })\n    }\n\n    @Test\n    fun insertTopic_newEntryIsIgnoredIfAlreadyExists() = runTest {\n        insertTopics()\n        topicDao.insertOrIgnoreTopics(\n            listOf(testTopicEntity(\"1\", \"compose\")),\n        )\n\n        val savedTopics = topicDao.getOneOffTopicEntities()\n\n        assertEquals(3, savedTopics.size)\n    }\n\n    @Test\n    fun upsertTopic_existingEntryIsUpdated() = runTest {\n        insertTopics()\n        topicDao.upsertTopics(\n            listOf(testTopicEntity(\"1\", \"newName\")),\n        )\n\n        val savedTopics = topicDao.getOneOffTopicEntities()\n\n        assertEquals(3, savedTopics.size)\n        assertEquals(\"newName\", savedTopics.first().name)\n    }\n\n    @Test\n    fun deleteTopics_byId_existingEntriesAreDeleted() = runTest {\n        insertTopics()\n        topicDao.deleteTopics(listOf(\"1\", \"2\"))\n\n        val savedTopics = topicDao.getOneOffTopicEntities()\n\n        assertEquals(1, savedTopics.size)\n        assertEquals(\"3\", savedTopics.first().id)\n    }\n\n    private suspend fun insertTopics() {\n        val topicEntities = listOf(\n            testTopicEntity(\"1\", \"compose\"),\n            testTopicEntity(\"2\", \"performance\"),\n            testTopicEntity(\"3\", \"headline\"),\n        )\n        topicDao.insertOrIgnoreTopics(topicEntities)\n    }\n}\n\nprivate fun testTopicEntity(\n    id: String = \"0\",\n    name: String,\n) = TopicEntity(\n    id = id,\n    name = name,\n    shortDescription = \"\",\n    longDescription = \"\",\n    url = \"\",\n    imageUrl = \"\",\n)\n"
  },
  {
    "path": "core/database/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<manifest />\n"
  },
  {
    "path": "core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/DatabaseMigrations.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.database\n\nimport androidx.room.DeleteColumn\nimport androidx.room.DeleteTable\nimport androidx.room.RenameColumn\nimport androidx.room.migration.AutoMigrationSpec\n\n/**\n * Automatic schema migrations sometimes require extra instructions to perform the migration, for\n * example, when a column is renamed. These extra instructions are placed here by creating a class\n * using the following naming convention `SchemaXtoY` where X is the schema version you're migrating\n * from and Y is the schema version you're migrating to. The class should implement\n * `AutoMigrationSpec`.\n */\ninternal object DatabaseMigrations {\n\n    @RenameColumn(\n        tableName = \"topics\",\n        fromColumnName = \"description\",\n        toColumnName = \"shortDescription\",\n    )\n    class Schema2to3 : AutoMigrationSpec\n\n    @DeleteColumn(\n        tableName = \"news_resources\",\n        columnName = \"episode_id\",\n    )\n    @DeleteTable.Entries(\n        DeleteTable(\n            tableName = \"episodes_authors\",\n        ),\n        DeleteTable(\n            tableName = \"episodes\",\n        ),\n    )\n    class Schema10to11 : AutoMigrationSpec\n\n    @DeleteTable.Entries(\n        DeleteTable(\n            tableName = \"news_resources_authors\",\n        ),\n        DeleteTable(\n            tableName = \"authors\",\n        ),\n    )\n    class Schema11to12 : AutoMigrationSpec\n}\n"
  },
  {
    "path": "core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/NiaDatabase.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.database\n\nimport androidx.room.AutoMigration\nimport androidx.room.Database\nimport androidx.room.RoomDatabase\nimport androidx.room.TypeConverters\nimport com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao\nimport com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceFtsDao\nimport com.google.samples.apps.nowinandroid.core.database.dao.RecentSearchQueryDao\nimport com.google.samples.apps.nowinandroid.core.database.dao.TopicDao\nimport com.google.samples.apps.nowinandroid.core.database.dao.TopicFtsDao\nimport com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity\nimport com.google.samples.apps.nowinandroid.core.database.model.NewsResourceFtsEntity\nimport com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef\nimport com.google.samples.apps.nowinandroid.core.database.model.RecentSearchQueryEntity\nimport com.google.samples.apps.nowinandroid.core.database.model.TopicEntity\nimport com.google.samples.apps.nowinandroid.core.database.model.TopicFtsEntity\nimport com.google.samples.apps.nowinandroid.core.database.util.InstantConverter\n\n@Database(\n    entities = [\n        NewsResourceEntity::class,\n        NewsResourceTopicCrossRef::class,\n        NewsResourceFtsEntity::class,\n        TopicEntity::class,\n        TopicFtsEntity::class,\n        RecentSearchQueryEntity::class,\n    ],\n    version = 14,\n    autoMigrations = [\n        AutoMigration(from = 1, to = 2),\n        AutoMigration(from = 2, to = 3, spec = DatabaseMigrations.Schema2to3::class),\n        AutoMigration(from = 3, to = 4),\n        AutoMigration(from = 4, to = 5),\n        AutoMigration(from = 5, to = 6),\n        AutoMigration(from = 6, to = 7),\n        AutoMigration(from = 7, to = 8),\n        AutoMigration(from = 8, to = 9),\n        AutoMigration(from = 9, to = 10),\n        AutoMigration(from = 10, to = 11, spec = DatabaseMigrations.Schema10to11::class),\n        AutoMigration(from = 11, to = 12, spec = DatabaseMigrations.Schema11to12::class),\n        AutoMigration(from = 12, to = 13),\n        AutoMigration(from = 13, to = 14),\n    ],\n    exportSchema = true,\n)\n@TypeConverters(\n    InstantConverter::class,\n)\ninternal abstract class NiaDatabase : RoomDatabase() {\n    abstract fun topicDao(): TopicDao\n    abstract fun newsResourceDao(): NewsResourceDao\n    abstract fun topicFtsDao(): TopicFtsDao\n    abstract fun newsResourceFtsDao(): NewsResourceFtsDao\n    abstract fun recentSearchQueryDao(): RecentSearchQueryDao\n}\n"
  },
  {
    "path": "core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.database.dao\n\nimport androidx.room.Dao\nimport androidx.room.Insert\nimport androidx.room.OnConflictStrategy\nimport androidx.room.Query\nimport androidx.room.Transaction\nimport androidx.room.Upsert\nimport com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity\nimport com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef\nimport com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource\nimport com.google.samples.apps.nowinandroid.core.model.data.NewsResource\nimport kotlinx.coroutines.flow.Flow\n\n/**\n * DAO for [NewsResource] and [NewsResourceEntity] access\n */\n@Dao\ninterface NewsResourceDao {\n\n    /**\n     * Fetches news resources that match the query parameters\n     */\n    @Transaction\n    @Query(\n        value = \"\"\"\n            SELECT * FROM news_resources\n            WHERE \n                CASE WHEN :useFilterNewsIds\n                    THEN id IN (:filterNewsIds)\n                    ELSE 1\n                END\n             AND\n                CASE WHEN :useFilterTopicIds\n                    THEN id IN\n                        (\n                            SELECT news_resource_id FROM news_resources_topics\n                            WHERE topic_id IN (:filterTopicIds)\n                        )\n                    ELSE 1\n                END\n            ORDER BY publish_date DESC\n    \"\"\",\n    )\n    fun getNewsResources(\n        useFilterTopicIds: Boolean = false,\n        filterTopicIds: Set<String> = emptySet(),\n        useFilterNewsIds: Boolean = false,\n        filterNewsIds: Set<String> = emptySet(),\n    ): Flow<List<PopulatedNewsResource>>\n\n    /**\n     * Fetches ids of news resources that match the query parameters\n     */\n    @Transaction\n    @Query(\n        value = \"\"\"\n            SELECT id FROM news_resources\n            WHERE \n                CASE WHEN :useFilterNewsIds\n                    THEN id IN (:filterNewsIds)\n                    ELSE 1\n                END\n             AND\n                CASE WHEN :useFilterTopicIds\n                    THEN id IN\n                        (\n                            SELECT news_resource_id FROM news_resources_topics\n                            WHERE topic_id IN (:filterTopicIds)\n                        )\n                    ELSE 1\n                END\n            ORDER BY publish_date DESC\n    \"\"\",\n    )\n    fun getNewsResourceIds(\n        useFilterTopicIds: Boolean = false,\n        filterTopicIds: Set<String> = emptySet(),\n        useFilterNewsIds: Boolean = false,\n        filterNewsIds: Set<String> = emptySet(),\n    ): Flow<List<String>>\n\n    /**\n     * Inserts or updates [newsResourceEntities] in the db under the specified primary keys\n     */\n    @Upsert\n    suspend fun upsertNewsResources(newsResourceEntities: List<NewsResourceEntity>)\n\n    @Insert(onConflict = OnConflictStrategy.IGNORE)\n    suspend fun insertOrIgnoreTopicCrossRefEntities(\n        newsResourceTopicCrossReferences: List<NewsResourceTopicCrossRef>,\n    )\n\n    /**\n     * Deletes rows in the db matching the specified [ids]\n     */\n    @Query(\n        value = \"\"\"\n            DELETE FROM news_resources\n            WHERE id in (:ids)\n        \"\"\",\n    )\n    suspend fun deleteNewsResources(ids: List<String>)\n}\n"
  },
  {
    "path": "core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceFtsDao.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.database.dao\n\nimport androidx.room.Dao\nimport androidx.room.Insert\nimport androidx.room.OnConflictStrategy\nimport androidx.room.Query\nimport com.google.samples.apps.nowinandroid.core.database.model.NewsResourceFtsEntity\nimport kotlinx.coroutines.flow.Flow\n\n/**\n * DAO for [NewsResourceFtsEntity] access.\n */\n@Dao\ninterface NewsResourceFtsDao {\n    @Insert(onConflict = OnConflictStrategy.REPLACE)\n    suspend fun insertAll(newsResources: List<NewsResourceFtsEntity>)\n\n    @Query(\"SELECT newsResourceId FROM newsResourcesFts WHERE newsResourcesFts MATCH :query\")\n    fun searchAllNewsResources(query: String): Flow<List<String>>\n\n    @Query(\"SELECT count(*) FROM newsResourcesFts\")\n    fun getCount(): Flow<Int>\n}\n"
  },
  {
    "path": "core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/dao/RecentSearchQueryDao.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.database.dao\n\nimport androidx.room.Dao\nimport androidx.room.Query\nimport androidx.room.Upsert\nimport com.google.samples.apps.nowinandroid.core.database.model.RecentSearchQueryEntity\nimport kotlinx.coroutines.flow.Flow\n\n/**\n * DAO for [RecentSearchQueryEntity] access\n */\n@Dao\ninterface RecentSearchQueryDao {\n    @Query(value = \"SELECT * FROM recentSearchQueries ORDER BY queriedDate DESC LIMIT :limit\")\n    fun getRecentSearchQueryEntities(limit: Int): Flow<List<RecentSearchQueryEntity>>\n\n    @Upsert\n    suspend fun insertOrReplaceRecentSearchQuery(recentSearchQuery: RecentSearchQueryEntity)\n\n    @Query(value = \"DELETE FROM recentSearchQueries\")\n    suspend fun clearRecentSearchQueries()\n}\n"
  },
  {
    "path": "core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.database.dao\n\nimport androidx.room.Dao\nimport androidx.room.Insert\nimport androidx.room.OnConflictStrategy\nimport androidx.room.Query\nimport androidx.room.Upsert\nimport com.google.samples.apps.nowinandroid.core.database.model.TopicEntity\nimport kotlinx.coroutines.flow.Flow\n\n/**\n * DAO for [TopicEntity] access\n */\n@Dao\ninterface TopicDao {\n    @Query(\n        value = \"\"\"\n        SELECT * FROM topics\n        WHERE id = :topicId\n    \"\"\",\n    )\n    fun getTopicEntity(topicId: String): Flow<TopicEntity>\n\n    @Query(value = \"SELECT * FROM topics\")\n    fun getTopicEntities(): Flow<List<TopicEntity>>\n\n    @Query(value = \"SELECT * FROM topics\")\n    suspend fun getOneOffTopicEntities(): List<TopicEntity>\n\n    @Query(\n        value = \"\"\"\n        SELECT * FROM topics\n        WHERE id IN (:ids)\n    \"\"\",\n    )\n    fun getTopicEntities(ids: Set<String>): Flow<List<TopicEntity>>\n\n    /**\n     * Inserts [topicEntities] into the db if they don't exist, and ignores those that do\n     */\n    @Insert(onConflict = OnConflictStrategy.IGNORE)\n    suspend fun insertOrIgnoreTopics(topicEntities: List<TopicEntity>): List<Long>\n\n    /**\n     * Inserts or updates [entities] in the db under the specified primary keys\n     */\n    @Upsert\n    suspend fun upsertTopics(entities: List<TopicEntity>)\n\n    /**\n     * Deletes rows in the db matching the specified [ids]\n     */\n    @Query(\n        value = \"\"\"\n            DELETE FROM topics\n            WHERE id in (:ids)\n        \"\"\",\n    )\n    suspend fun deleteTopics(ids: List<String>)\n}\n"
  },
  {
    "path": "core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/dao/TopicFtsDao.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.database.dao\n\nimport androidx.room.Dao\nimport androidx.room.Insert\nimport androidx.room.OnConflictStrategy\nimport androidx.room.Query\nimport com.google.samples.apps.nowinandroid.core.database.model.TopicFtsEntity\nimport kotlinx.coroutines.flow.Flow\n\n/**\n * DAO for [TopicFtsEntity] access.\n */\n@Dao\ninterface TopicFtsDao {\n    @Insert(onConflict = OnConflictStrategy.REPLACE)\n    suspend fun insertAll(topics: List<TopicFtsEntity>)\n\n    @Query(\"SELECT topicId FROM topicsFts WHERE topicsFts MATCH :query\")\n    fun searchAllTopics(query: String): Flow<List<String>>\n\n    @Query(\"SELECT count(*) FROM topicsFts\")\n    fun getCount(): Flow<Int>\n}\n"
  },
  {
    "path": "core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/di/DaosModule.kt",
    "content": "/*\n * Copyright 2024 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.database.di\n\nimport com.google.samples.apps.nowinandroid.core.database.NiaDatabase\nimport com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao\nimport com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceFtsDao\nimport com.google.samples.apps.nowinandroid.core.database.dao.RecentSearchQueryDao\nimport com.google.samples.apps.nowinandroid.core.database.dao.TopicDao\nimport com.google.samples.apps.nowinandroid.core.database.dao.TopicFtsDao\nimport dagger.Module\nimport dagger.Provides\nimport dagger.hilt.InstallIn\nimport dagger.hilt.components.SingletonComponent\n\n@Module\n@InstallIn(SingletonComponent::class)\ninternal object DaosModule {\n    @Provides\n    fun providesTopicsDao(\n        database: NiaDatabase,\n    ): TopicDao = database.topicDao()\n\n    @Provides\n    fun providesNewsResourceDao(\n        database: NiaDatabase,\n    ): NewsResourceDao = database.newsResourceDao()\n\n    @Provides\n    fun providesTopicFtsDao(\n        database: NiaDatabase,\n    ): TopicFtsDao = database.topicFtsDao()\n\n    @Provides\n    fun providesNewsResourceFtsDao(\n        database: NiaDatabase,\n    ): NewsResourceFtsDao = database.newsResourceFtsDao()\n\n    @Provides\n    fun providesRecentSearchQueryDao(\n        database: NiaDatabase,\n    ): RecentSearchQueryDao = database.recentSearchQueryDao()\n}\n"
  },
  {
    "path": "core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/di/DatabaseModule.kt",
    "content": "/*\n * Copyright 2024 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.database.di\n\nimport android.content.Context\nimport androidx.room.Room\nimport com.google.samples.apps.nowinandroid.core.database.NiaDatabase\nimport dagger.Module\nimport dagger.Provides\nimport dagger.hilt.InstallIn\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport dagger.hilt.components.SingletonComponent\nimport javax.inject.Singleton\n\n@Module\n@InstallIn(SingletonComponent::class)\ninternal object DatabaseModule {\n    @Provides\n    @Singleton\n    fun providesNiaDatabase(\n        @ApplicationContext context: Context,\n    ): NiaDatabase = Room.databaseBuilder(\n        context,\n        NiaDatabase::class.java,\n        \"nia-database\",\n    ).build()\n}\n"
  },
  {
    "path": "core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceEntity.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.database.model\n\nimport androidx.room.ColumnInfo\nimport androidx.room.Entity\nimport androidx.room.PrimaryKey\nimport com.google.samples.apps.nowinandroid.core.model.data.NewsResource\nimport kotlinx.datetime.Instant\n\n/**\n * Defines an NiA news resource.\n */\n@Entity(\n    tableName = \"news_resources\",\n)\ndata class NewsResourceEntity(\n    @PrimaryKey\n    val id: String,\n    val title: String,\n    val content: String,\n    val url: String,\n    @ColumnInfo(name = \"header_image_url\")\n    val headerImageUrl: String?,\n    @ColumnInfo(name = \"publish_date\")\n    val publishDate: Instant,\n    val type: String,\n)\n\nfun NewsResourceEntity.asExternalModel() = NewsResource(\n    id = id,\n    title = title,\n    content = content,\n    url = url,\n    headerImageUrl = headerImageUrl,\n    publishDate = publishDate,\n    type = type,\n    topics = emptyList(),\n)\n"
  },
  {
    "path": "core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceFtsEntity.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.database.model\n\nimport androidx.room.ColumnInfo\nimport androidx.room.Entity\nimport androidx.room.Fts4\n\n/**\n * Fts entity for the news resources. See https://developer.android.com/reference/androidx/room/Fts4.\n */\n@Entity(tableName = \"newsResourcesFts\")\n@Fts4\ndata class NewsResourceFtsEntity(\n\n    @ColumnInfo(name = \"newsResourceId\")\n    val newsResourceId: String,\n\n    @ColumnInfo(name = \"title\")\n    val title: String,\n\n    @ColumnInfo(name = \"content\")\n    val content: String,\n)\n"
  },
  {
    "path": "core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceTopicCrossRef.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.database.model\n\nimport androidx.room.ColumnInfo\nimport androidx.room.Entity\nimport androidx.room.ForeignKey\nimport androidx.room.Index\n\n/**\n * Cross reference for many to many relationship between [NewsResourceEntity] and [TopicEntity]\n */\n@Entity(\n    tableName = \"news_resources_topics\",\n    primaryKeys = [\"news_resource_id\", \"topic_id\"],\n    foreignKeys = [\n        ForeignKey(\n            entity = NewsResourceEntity::class,\n            parentColumns = [\"id\"],\n            childColumns = [\"news_resource_id\"],\n            onDelete = ForeignKey.CASCADE,\n        ),\n        ForeignKey(\n            entity = TopicEntity::class,\n            parentColumns = [\"id\"],\n            childColumns = [\"topic_id\"],\n            onDelete = ForeignKey.CASCADE,\n        ),\n    ],\n    indices = [\n        Index(value = [\"news_resource_id\"]),\n        Index(value = [\"topic_id\"]),\n    ],\n)\ndata class NewsResourceTopicCrossRef(\n    @ColumnInfo(name = \"news_resource_id\")\n    val newsResourceId: String,\n    @ColumnInfo(name = \"topic_id\")\n    val topicId: String,\n)\n"
  },
  {
    "path": "core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/model/PopulatedNewsResource.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.database.model\n\nimport androidx.room.Embedded\nimport androidx.room.Junction\nimport androidx.room.Relation\nimport com.google.samples.apps.nowinandroid.core.model.data.NewsResource\n\n/**\n * External data layer representation of a fully populated NiA news resource\n */\ndata class PopulatedNewsResource(\n    @Embedded\n    val entity: NewsResourceEntity,\n    @Relation(\n        parentColumn = \"id\",\n        entityColumn = \"id\",\n        associateBy = Junction(\n            value = NewsResourceTopicCrossRef::class,\n            parentColumn = \"news_resource_id\",\n            entityColumn = \"topic_id\",\n        ),\n    )\n    val topics: List<TopicEntity>,\n)\n\nfun PopulatedNewsResource.asExternalModel() = NewsResource(\n    id = entity.id,\n    title = entity.title,\n    content = entity.content,\n    url = entity.url,\n    headerImageUrl = entity.headerImageUrl,\n    publishDate = entity.publishDate,\n    type = entity.type,\n    topics = topics.map(TopicEntity::asExternalModel),\n)\n\nfun PopulatedNewsResource.asFtsEntity() = NewsResourceFtsEntity(\n    newsResourceId = entity.id,\n    title = entity.title,\n    content = entity.content,\n)\n"
  },
  {
    "path": "core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/model/RecentSearchQueryEntity.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.database.model\n\nimport androidx.room.ColumnInfo\nimport androidx.room.Entity\nimport androidx.room.PrimaryKey\nimport kotlinx.datetime.Instant\n\n/**\n * Defines an database entity that stored recent search queries.\n */\n@Entity(\n    tableName = \"recentSearchQueries\",\n)\ndata class RecentSearchQueryEntity(\n    @PrimaryKey\n    val query: String,\n    @ColumnInfo\n    val queriedDate: Instant,\n)\n"
  },
  {
    "path": "core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/model/TopicEntity.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.database.model\n\nimport androidx.room.ColumnInfo\nimport androidx.room.Entity\nimport androidx.room.PrimaryKey\nimport com.google.samples.apps.nowinandroid.core.model.data.Topic\n\n/**\n * Defines a topic a user may follow.\n * It has a many to many relationship with [NewsResourceEntity]\n */\n@Entity(\n    tableName = \"topics\",\n)\ndata class TopicEntity(\n    @PrimaryKey\n    val id: String,\n    val name: String,\n    val shortDescription: String,\n    @ColumnInfo(defaultValue = \"\")\n    val longDescription: String,\n    @ColumnInfo(defaultValue = \"\")\n    val url: String,\n    @ColumnInfo(defaultValue = \"\")\n    val imageUrl: String,\n)\n\nfun TopicEntity.asExternalModel() = Topic(\n    id = id,\n    name = name,\n    shortDescription = shortDescription,\n    longDescription = longDescription,\n    url = url,\n    imageUrl = imageUrl,\n)\n"
  },
  {
    "path": "core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/model/TopicFtsEntity.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.database.model\n\nimport androidx.room.ColumnInfo\nimport androidx.room.Entity\nimport androidx.room.Fts4\n\n/**\n * Fts entity for the topic. See https://developer.android.com/reference/androidx/room/Fts4.\n */\n@Entity(tableName = \"topicsFts\")\n@Fts4\ndata class TopicFtsEntity(\n\n    @ColumnInfo(name = \"topicId\")\n    val topicId: String,\n\n    @ColumnInfo(name = \"name\")\n    val name: String,\n\n    @ColumnInfo(name = \"shortDescription\")\n    val shortDescription: String,\n\n    @ColumnInfo(name = \"longDescription\")\n    val longDescription: String,\n)\n\nfun TopicEntity.asFtsEntity() = TopicFtsEntity(\n    topicId = id,\n    name = name,\n    shortDescription = shortDescription,\n    longDescription = longDescription,\n)\n"
  },
  {
    "path": "core/database/src/main/kotlin/com/google/samples/apps/nowinandroid/core/database/util/InstantConverter.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.database.util\n\nimport androidx.room.TypeConverter\nimport kotlinx.datetime.Instant\n\ninternal class InstantConverter {\n    @TypeConverter\n    fun longToInstant(value: Long?): Instant? =\n        value?.let(Instant::fromEpochMilliseconds)\n\n    @TypeConverter\n    fun instantToLong(instant: Instant?): Long? =\n        instant?.toEpochMilliseconds()\n}\n"
  },
  {
    "path": "core/datastore/.gitignore",
    "content": "/build"
  },
  {
    "path": "core/datastore/README.md",
    "content": "# `:core:datastore`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :core\n    direction TB\n    :core:common[common]:::jvm-library\n    :core:datastore[datastore]:::android-library\n    :core:datastore-proto[datastore-proto]:::jvm-library\n    :core:model[model]:::jvm-library\n  end\n\n  :core:datastore -.-> :core:common\n  :core:datastore --> :core:datastore-proto\n  :core:datastore --> :core:model\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "core/datastore/build.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nplugins {\n    alias(libs.plugins.nowinandroid.android.library)\n    alias(libs.plugins.nowinandroid.android.library.jacoco)\n    alias(libs.plugins.nowinandroid.hilt)\n}\n\nandroid {\n    defaultConfig {\n        consumerProguardFiles(\"consumer-proguard-rules.pro\")\n    }\n    namespace = \"com.google.samples.apps.nowinandroid.core.datastore\"\n}\n\ndependencies {\n    api(libs.androidx.dataStore)\n    api(projects.core.datastoreProto)\n    api(projects.core.model)\n\n    implementation(projects.core.common)\n\n    testImplementation(projects.core.datastoreTest)\n    testImplementation(libs.kotlinx.coroutines.test)\n}\n"
  },
  {
    "path": "core/datastore/consumer-proguard-rules.pro",
    "content": "# Keep DataStore fields\n-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite* {\n   <fields>;\n}"
  },
  {
    "path": "core/datastore/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<manifest />\n"
  },
  {
    "path": "core/datastore/src/main/kotlin/com/google/samples/apps/nowinandroid/core/datastore/ChangeListVersions.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.datastore\n\n/**\n * Class summarizing the local version of each model for sync\n */\ndata class ChangeListVersions(\n    val topicVersion: Int = -1,\n    val newsResourceVersion: Int = -1,\n)\n"
  },
  {
    "path": "core/datastore/src/main/kotlin/com/google/samples/apps/nowinandroid/core/datastore/IntToStringIdsMigration.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.datastore\n\nimport androidx.datastore.core.DataMigration\n\n/**\n * Migrates saved ids from [Int] to [String] types\n */\ninternal object IntToStringIdsMigration : DataMigration<UserPreferences> {\n\n    override suspend fun cleanUp() = Unit\n\n    override suspend fun migrate(currentData: UserPreferences): UserPreferences =\n        currentData.copy {\n            // Migrate topic ids\n            deprecatedFollowedTopicIds.clear()\n            deprecatedFollowedTopicIds.addAll(\n                currentData.deprecatedIntFollowedTopicIdsList.map(Int::toString),\n            )\n            deprecatedIntFollowedTopicIds.clear()\n\n            // Migrate author ids\n            deprecatedFollowedAuthorIds.clear()\n            deprecatedFollowedAuthorIds.addAll(\n                currentData.deprecatedIntFollowedAuthorIdsList.map(Int::toString),\n            )\n            deprecatedIntFollowedAuthorIds.clear()\n\n            // Mark migration as complete\n            hasDoneIntToStringIdMigration = true\n        }\n\n    override suspend fun shouldMigrate(currentData: UserPreferences): Boolean =\n        !currentData.hasDoneIntToStringIdMigration\n}\n"
  },
  {
    "path": "core/datastore/src/main/kotlin/com/google/samples/apps/nowinandroid/core/datastore/ListToMapMigration.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.datastore\n\nimport androidx.datastore.core.DataMigration\n\n/**\n * Migrates from using lists to maps for user data.\n */\ninternal object ListToMapMigration : DataMigration<UserPreferences> {\n\n    override suspend fun cleanUp() = Unit\n\n    override suspend fun migrate(currentData: UserPreferences): UserPreferences =\n        currentData.copy {\n            // Migrate topic id lists\n            followedTopicIds.clear()\n            followedTopicIds.putAll(\n                currentData.deprecatedFollowedTopicIdsList.associateWith { true },\n            )\n            deprecatedFollowedTopicIds.clear()\n\n            // Migrate author ids\n            followedAuthorIds.clear()\n            followedAuthorIds.putAll(\n                currentData.deprecatedFollowedAuthorIdsList.associateWith { true },\n            )\n            deprecatedFollowedAuthorIds.clear()\n\n            // Migrate bookmarks\n            bookmarkedNewsResourceIds.clear()\n            bookmarkedNewsResourceIds.putAll(\n                currentData.deprecatedBookmarkedNewsResourceIdsList.associateWith { true },\n            )\n            deprecatedBookmarkedNewsResourceIds.clear()\n\n            // Mark migration as complete\n            hasDoneListToMapMigration = true\n        }\n\n    override suspend fun shouldMigrate(currentData: UserPreferences): Boolean =\n        !currentData.hasDoneListToMapMigration\n}\n"
  },
  {
    "path": "core/datastore/src/main/kotlin/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.datastore\n\nimport android.util.Log\nimport androidx.datastore.core.DataStore\nimport com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig\nimport com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand\nimport com.google.samples.apps.nowinandroid.core.model.data.UserData\nimport kotlinx.coroutines.flow.firstOrNull\nimport kotlinx.coroutines.flow.map\nimport java.io.IOException\nimport javax.inject.Inject\n\nclass NiaPreferencesDataSource @Inject constructor(\n    private val userPreferences: DataStore<UserPreferences>,\n) {\n    val userData = userPreferences.data\n        .map {\n            UserData(\n                bookmarkedNewsResources = it.bookmarkedNewsResourceIdsMap.keys,\n                viewedNewsResources = it.viewedNewsResourceIdsMap.keys,\n                followedTopics = it.followedTopicIdsMap.keys,\n                themeBrand = when (it.themeBrand) {\n                    null,\n                    ThemeBrandProto.THEME_BRAND_UNSPECIFIED,\n                    ThemeBrandProto.UNRECOGNIZED,\n                    ThemeBrandProto.THEME_BRAND_DEFAULT,\n                    -> ThemeBrand.DEFAULT\n                    ThemeBrandProto.THEME_BRAND_ANDROID -> ThemeBrand.ANDROID\n                },\n                darkThemeConfig = when (it.darkThemeConfig) {\n                    null,\n                    DarkThemeConfigProto.DARK_THEME_CONFIG_UNSPECIFIED,\n                    DarkThemeConfigProto.UNRECOGNIZED,\n                    DarkThemeConfigProto.DARK_THEME_CONFIG_FOLLOW_SYSTEM,\n                    ->\n                        DarkThemeConfig.FOLLOW_SYSTEM\n                    DarkThemeConfigProto.DARK_THEME_CONFIG_LIGHT ->\n                        DarkThemeConfig.LIGHT\n                    DarkThemeConfigProto.DARK_THEME_CONFIG_DARK -> DarkThemeConfig.DARK\n                },\n                useDynamicColor = it.useDynamicColor,\n                shouldHideOnboarding = it.shouldHideOnboarding,\n            )\n        }\n\n    suspend fun setFollowedTopicIds(topicIds: Set<String>) {\n        try {\n            userPreferences.updateData {\n                it.copy {\n                    followedTopicIds.clear()\n                    followedTopicIds.putAll(topicIds.associateWith { true })\n                    updateShouldHideOnboardingIfNecessary()\n                }\n            }\n        } catch (ioException: IOException) {\n            Log.e(\"NiaPreferences\", \"Failed to update user preferences\", ioException)\n        }\n    }\n\n    suspend fun setTopicIdFollowed(topicId: String, followed: Boolean) {\n        try {\n            userPreferences.updateData {\n                it.copy {\n                    if (followed) {\n                        followedTopicIds.put(topicId, true)\n                    } else {\n                        followedTopicIds.remove(topicId)\n                    }\n                    updateShouldHideOnboardingIfNecessary()\n                }\n            }\n        } catch (ioException: IOException) {\n            Log.e(\"NiaPreferences\", \"Failed to update user preferences\", ioException)\n        }\n    }\n\n    suspend fun setThemeBrand(themeBrand: ThemeBrand) {\n        userPreferences.updateData {\n            it.copy {\n                this.themeBrand = when (themeBrand) {\n                    ThemeBrand.DEFAULT -> ThemeBrandProto.THEME_BRAND_DEFAULT\n                    ThemeBrand.ANDROID -> ThemeBrandProto.THEME_BRAND_ANDROID\n                }\n            }\n        }\n    }\n\n    suspend fun setDynamicColorPreference(useDynamicColor: Boolean) {\n        userPreferences.updateData {\n            it.copy { this.useDynamicColor = useDynamicColor }\n        }\n    }\n\n    suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) {\n        userPreferences.updateData {\n            it.copy {\n                this.darkThemeConfig = when (darkThemeConfig) {\n                    DarkThemeConfig.FOLLOW_SYSTEM ->\n                        DarkThemeConfigProto.DARK_THEME_CONFIG_FOLLOW_SYSTEM\n                    DarkThemeConfig.LIGHT -> DarkThemeConfigProto.DARK_THEME_CONFIG_LIGHT\n                    DarkThemeConfig.DARK -> DarkThemeConfigProto.DARK_THEME_CONFIG_DARK\n                }\n            }\n        }\n    }\n\n    suspend fun setNewsResourceBookmarked(newsResourceId: String, bookmarked: Boolean) {\n        try {\n            userPreferences.updateData {\n                it.copy {\n                    if (bookmarked) {\n                        bookmarkedNewsResourceIds.put(newsResourceId, true)\n                    } else {\n                        bookmarkedNewsResourceIds.remove(newsResourceId)\n                    }\n                }\n            }\n        } catch (ioException: IOException) {\n            Log.e(\"NiaPreferences\", \"Failed to update user preferences\", ioException)\n        }\n    }\n\n    suspend fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) {\n        setNewsResourcesViewed(listOf(newsResourceId), viewed)\n    }\n\n    suspend fun setNewsResourcesViewed(newsResourceIds: List<String>, viewed: Boolean) {\n        userPreferences.updateData { prefs ->\n            prefs.copy {\n                newsResourceIds.forEach { id ->\n                    if (viewed) {\n                        viewedNewsResourceIds.put(id, true)\n                    } else {\n                        viewedNewsResourceIds.remove(id)\n                    }\n                }\n            }\n        }\n    }\n\n    suspend fun getChangeListVersions() = userPreferences.data\n        .map {\n            ChangeListVersions(\n                topicVersion = it.topicChangeListVersion,\n                newsResourceVersion = it.newsResourceChangeListVersion,\n            )\n        }\n        .firstOrNull() ?: ChangeListVersions()\n\n    /**\n     * Update the [ChangeListVersions] using [update].\n     */\n    suspend fun updateChangeListVersion(update: ChangeListVersions.() -> ChangeListVersions) {\n        try {\n            userPreferences.updateData { currentPreferences ->\n                val updatedChangeListVersions = update(\n                    ChangeListVersions(\n                        topicVersion = currentPreferences.topicChangeListVersion,\n                        newsResourceVersion = currentPreferences.newsResourceChangeListVersion,\n                    ),\n                )\n\n                currentPreferences.copy {\n                    topicChangeListVersion = updatedChangeListVersions.topicVersion\n                    newsResourceChangeListVersion = updatedChangeListVersions.newsResourceVersion\n                }\n            }\n        } catch (ioException: IOException) {\n            Log.e(\"NiaPreferences\", \"Failed to update user preferences\", ioException)\n        }\n    }\n\n    suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) {\n        userPreferences.updateData {\n            it.copy { this.shouldHideOnboarding = shouldHideOnboarding }\n        }\n    }\n}\n\nprivate fun UserPreferencesKt.Dsl.updateShouldHideOnboardingIfNecessary() {\n    if (followedTopicIds.isEmpty() && followedAuthorIds.isEmpty()) {\n        shouldHideOnboarding = false\n    }\n}\n"
  },
  {
    "path": "core/datastore/src/main/kotlin/com/google/samples/apps/nowinandroid/core/datastore/UserPreferencesSerializer.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.datastore\n\nimport androidx.datastore.core.CorruptionException\nimport androidx.datastore.core.Serializer\nimport com.google.protobuf.InvalidProtocolBufferException\nimport java.io.InputStream\nimport java.io.OutputStream\nimport javax.inject.Inject\n\n/**\n * An [androidx.datastore.core.Serializer] for the [UserPreferences] proto.\n */\nclass UserPreferencesSerializer @Inject constructor() : Serializer<UserPreferences> {\n    override val defaultValue: UserPreferences = UserPreferences.getDefaultInstance()\n\n    override suspend fun readFrom(input: InputStream): UserPreferences =\n        try {\n            // readFrom is already called on the data store background thread\n            UserPreferences.parseFrom(input)\n        } catch (exception: InvalidProtocolBufferException) {\n            throw CorruptionException(\"Cannot read proto.\", exception)\n        }\n\n    override suspend fun writeTo(t: UserPreferences, output: OutputStream) {\n        // writeTo is already called on the data store background thread\n        t.writeTo(output)\n    }\n}\n"
  },
  {
    "path": "core/datastore/src/main/kotlin/com/google/samples/apps/nowinandroid/core/datastore/di/DataStoreModule.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.datastore.di\n\nimport android.content.Context\nimport androidx.datastore.core.DataStore\nimport androidx.datastore.core.DataStoreFactory\nimport androidx.datastore.dataStoreFile\nimport com.google.samples.apps.nowinandroid.core.common.network.Dispatcher\nimport com.google.samples.apps.nowinandroid.core.common.network.NiaDispatchers.IO\nimport com.google.samples.apps.nowinandroid.core.common.network.di.ApplicationScope\nimport com.google.samples.apps.nowinandroid.core.datastore.IntToStringIdsMigration\nimport com.google.samples.apps.nowinandroid.core.datastore.UserPreferences\nimport com.google.samples.apps.nowinandroid.core.datastore.UserPreferencesSerializer\nimport dagger.Module\nimport dagger.Provides\nimport dagger.hilt.InstallIn\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport dagger.hilt.components.SingletonComponent\nimport kotlinx.coroutines.CoroutineDispatcher\nimport kotlinx.coroutines.CoroutineScope\nimport javax.inject.Singleton\n\n@Module\n@InstallIn(SingletonComponent::class)\nobject DataStoreModule {\n\n    @Provides\n    @Singleton\n    internal fun providesUserPreferencesDataStore(\n        @ApplicationContext context: Context,\n        @Dispatcher(IO) ioDispatcher: CoroutineDispatcher,\n        @ApplicationScope scope: CoroutineScope,\n        userPreferencesSerializer: UserPreferencesSerializer,\n    ): DataStore<UserPreferences> =\n        DataStoreFactory.create(\n            serializer = userPreferencesSerializer,\n            scope = CoroutineScope(scope.coroutineContext + ioDispatcher),\n            migrations = listOf(\n                IntToStringIdsMigration,\n            ),\n        ) {\n            context.dataStoreFile(\"user_preferences.pb\")\n        }\n}\n"
  },
  {
    "path": "core/datastore/src/test/kotlin/com/google/samples/apps/nowinandroid/core/datastore/IntToStringIdsMigrationTest.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.datastore\n\nimport kotlinx.coroutines.test.runTest\nimport org.junit.Test\nimport kotlin.test.assertEquals\nimport kotlin.test.assertTrue\n\n/**\n * Unit test for [IntToStringIdsMigration]\n */\nclass IntToStringIdsMigrationTest {\n\n    @Test\n    fun IntToStringIdsMigration_should_migrate_topic_ids() = runTest {\n        // Set up existing preferences with topic int ids\n        val preMigrationUserPreferences = userPreferences {\n            deprecatedIntFollowedTopicIds.addAll(listOf(1, 2, 3))\n        }\n        // Assert that there are no string topic ids yet\n        assertEquals(\n            emptyList<String>(),\n            preMigrationUserPreferences.deprecatedFollowedTopicIdsList,\n        )\n\n        // Run the migration\n        val postMigrationUserPreferences =\n            IntToStringIdsMigration.migrate(preMigrationUserPreferences)\n\n        // Assert the deprecated int topic ids have been migrated to the string topic ids\n        assertEquals(\n            userPreferences {\n                deprecatedFollowedTopicIds.addAll(listOf(\"1\", \"2\", \"3\"))\n                hasDoneIntToStringIdMigration = true\n            },\n            postMigrationUserPreferences,\n        )\n\n        // Assert that the migration has been marked complete\n        assertTrue(postMigrationUserPreferences.hasDoneIntToStringIdMigration)\n    }\n\n    @Test\n    fun IntToStringIdsMigration_should_migrate_author_ids() = runTest {\n        // Set up existing preferences with author int ids\n        val preMigrationUserPreferences = userPreferences {\n            deprecatedIntFollowedAuthorIds.addAll(listOf(4, 5, 6))\n        }\n        // Assert that there are no string author ids yet\n        assertEquals(\n            emptyList<String>(),\n            preMigrationUserPreferences.deprecatedFollowedAuthorIdsList,\n        )\n\n        // Run the migration\n        val postMigrationUserPreferences =\n            IntToStringIdsMigration.migrate(preMigrationUserPreferences)\n\n        // Assert the deprecated int author ids have been migrated to the string author ids\n        assertEquals(\n            userPreferences {\n                deprecatedFollowedAuthorIds.addAll(listOf(\"4\", \"5\", \"6\"))\n                hasDoneIntToStringIdMigration = true\n            },\n            postMigrationUserPreferences,\n        )\n\n        // Assert that the migration has been marked complete\n        assertTrue(postMigrationUserPreferences.hasDoneIntToStringIdMigration)\n    }\n}\n"
  },
  {
    "path": "core/datastore/src/test/kotlin/com/google/samples/apps/nowinandroid/core/datastore/ListToMapMigrationTest.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.datastore\n\nimport kotlinx.coroutines.test.runTest\nimport org.junit.Test\nimport kotlin.test.assertEquals\nimport kotlin.test.assertTrue\n\nclass ListToMapMigrationTest {\n\n    @Test\n    fun ListToMapMigration_should_migrate_topic_ids() = runTest {\n        // Set up existing preferences with topic ids\n        val preMigrationUserPreferences = userPreferences {\n            deprecatedFollowedTopicIds.addAll(listOf(\"1\", \"2\", \"3\"))\n        }\n        // Assert that there are no topic ids in the map yet\n        assertEquals(\n            emptyMap<String, Boolean>(),\n            preMigrationUserPreferences.followedTopicIdsMap,\n        )\n\n        // Run the migration\n        val postMigrationUserPreferences =\n            ListToMapMigration.migrate(preMigrationUserPreferences)\n\n        // Assert the deprecated topic ids have been migrated to the topic ids map\n        assertEquals(\n            mapOf(\"1\" to true, \"2\" to true, \"3\" to true),\n            postMigrationUserPreferences.followedTopicIdsMap,\n        )\n\n        // Assert that the migration has been marked complete\n        assertTrue(postMigrationUserPreferences.hasDoneListToMapMigration)\n    }\n\n    @Test\n    fun ListToMapMigration_should_migrate_author_ids() = runTest {\n        // Set up existing preferences with author ids\n        val preMigrationUserPreferences = userPreferences {\n            deprecatedFollowedAuthorIds.addAll(listOf(\"4\", \"5\", \"6\"))\n        }\n        // Assert that there are no author ids in the map yet\n        assertEquals(\n            emptyMap<String, Boolean>(),\n            preMigrationUserPreferences.followedAuthorIdsMap,\n        )\n\n        // Run the migration\n        val postMigrationUserPreferences =\n            ListToMapMigration.migrate(preMigrationUserPreferences)\n\n        // Assert the deprecated author ids have been migrated to the author ids map\n        assertEquals(\n            mapOf(\"4\" to true, \"5\" to true, \"6\" to true),\n            postMigrationUserPreferences.followedAuthorIdsMap,\n        )\n\n        // Assert that the migration has been marked complete\n        assertTrue(postMigrationUserPreferences.hasDoneListToMapMigration)\n    }\n\n    @Test\n    fun ListToMapMigration_should_migrate_bookmarks() = runTest {\n        // Set up existing preferences with bookmarks\n        val preMigrationUserPreferences = userPreferences {\n            deprecatedBookmarkedNewsResourceIds.addAll(listOf(\"7\", \"8\", \"9\"))\n        }\n        // Assert that there are no bookmarks in the map yet\n        assertEquals(\n            emptyMap<String, Boolean>(),\n            preMigrationUserPreferences.bookmarkedNewsResourceIdsMap,\n        )\n\n        // Run the migration\n        val postMigrationUserPreferences =\n            ListToMapMigration.migrate(preMigrationUserPreferences)\n\n        // Assert the deprecated bookmarks have been migrated to the bookmarks map\n        assertEquals(\n            mapOf(\"7\" to true, \"8\" to true, \"9\" to true),\n            postMigrationUserPreferences.bookmarkedNewsResourceIdsMap,\n        )\n\n        // Assert that the migration has been marked complete\n        assertTrue(postMigrationUserPreferences.hasDoneListToMapMigration)\n    }\n}\n"
  },
  {
    "path": "core/datastore/src/test/kotlin/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSourceTest.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.datastore\n\nimport com.google.samples.apps.nowinandroid.core.datastore.test.InMemoryDataStore\nimport kotlinx.coroutines.flow.first\nimport kotlinx.coroutines.test.TestScope\nimport kotlinx.coroutines.test.UnconfinedTestDispatcher\nimport kotlinx.coroutines.test.runTest\nimport org.junit.Before\nimport org.junit.Test\nimport kotlin.test.assertFalse\nimport kotlin.test.assertTrue\n\nclass NiaPreferencesDataSourceTest {\n\n    private val testScope = TestScope(UnconfinedTestDispatcher())\n\n    private lateinit var subject: NiaPreferencesDataSource\n\n    @Before\n    fun setup() {\n        subject = NiaPreferencesDataSource(InMemoryDataStore(UserPreferences.getDefaultInstance()))\n    }\n\n    @Test\n    fun shouldHideOnboardingIsFalseByDefault() = testScope.runTest {\n        assertFalse(subject.userData.first().shouldHideOnboarding)\n    }\n\n    @Test\n    fun userShouldHideOnboardingIsTrueWhenSet() = testScope.runTest {\n        subject.setShouldHideOnboarding(true)\n        assertTrue(subject.userData.first().shouldHideOnboarding)\n    }\n\n    @Test\n    fun userShouldHideOnboarding_unfollowsLastTopic_shouldHideOnboardingIsFalse() =\n        testScope.runTest {\n            // Given: user completes onboarding by selecting a single topic.\n            subject.setTopicIdFollowed(\"1\", true)\n            subject.setShouldHideOnboarding(true)\n\n            // When: they unfollow that topic.\n            subject.setTopicIdFollowed(\"1\", false)\n\n            // Then: onboarding should be shown again\n            assertFalse(subject.userData.first().shouldHideOnboarding)\n        }\n\n    @Test\n    fun userShouldHideOnboarding_unfollowsAllTopics_shouldHideOnboardingIsFalse() =\n        testScope.runTest {\n            // Given: user completes onboarding by selecting several topics.\n            subject.setFollowedTopicIds(setOf(\"1\", \"2\"))\n            subject.setShouldHideOnboarding(true)\n\n            // When: they unfollow those topics.\n            subject.setFollowedTopicIds(emptySet())\n\n            // Then: onboarding should be shown again\n            assertFalse(subject.userData.first().shouldHideOnboarding)\n        }\n\n    @Test\n    fun shouldUseDynamicColorFalseByDefault() = testScope.runTest {\n        assertFalse(subject.userData.first().useDynamicColor)\n    }\n\n    @Test\n    fun userShouldUseDynamicColorIsTrueWhenSet() = testScope.runTest {\n        subject.setDynamicColorPreference(true)\n        assertTrue(subject.userData.first().useDynamicColor)\n    }\n}\n"
  },
  {
    "path": "core/datastore/src/test/kotlin/com/google/samples/apps/nowinandroid/core/datastore/UserPreferencesSerializerTest.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.datastore\n\nimport androidx.datastore.core.CorruptionException\nimport kotlinx.coroutines.test.runTest\nimport org.junit.Test\nimport java.io.ByteArrayInputStream\nimport java.io.ByteArrayOutputStream\nimport kotlin.test.assertEquals\n\nclass UserPreferencesSerializerTest {\n    private val userPreferencesSerializer = UserPreferencesSerializer()\n\n    @Test\n    fun defaultUserPreferences_isEmpty() {\n        assertEquals(\n            userPreferences {\n                // Default value\n            },\n            userPreferencesSerializer.defaultValue,\n        )\n    }\n\n    @Test\n    fun writingAndReadingUserPreferences_outputsCorrectValue() = runTest {\n        val expectedUserPreferences = userPreferences {\n            followedTopicIds.put(\"0\", true)\n            followedTopicIds.put(\"1\", true)\n        }\n\n        val outputStream = ByteArrayOutputStream()\n\n        expectedUserPreferences.writeTo(outputStream)\n\n        val inputStream = ByteArrayInputStream(outputStream.toByteArray())\n\n        val actualUserPreferences = userPreferencesSerializer.readFrom(inputStream)\n\n        assertEquals(\n            expectedUserPreferences,\n            actualUserPreferences,\n        )\n    }\n\n    @Test(expected = CorruptionException::class)\n    fun readingInvalidUserPreferences_throwsCorruptionException() = runTest {\n        userPreferencesSerializer.readFrom(ByteArrayInputStream(byteArrayOf(0)))\n    }\n}\n"
  },
  {
    "path": "core/datastore-proto/README.md",
    "content": "# `:core:datastore-proto`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :core\n    direction TB\n    :core:datastore-proto[datastore-proto]:::jvm-library\n  end\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "core/datastore-proto/build.gradle.kts",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nplugins {\n    alias(libs.plugins.nowinandroid.jvm.library)\n    alias(libs.plugins.protobuf)\n}\n\n// Setup protobuf configuration, generating lite Java and Kotlin classes\nprotobuf {\n    protoc {\n        artifact = libs.protobuf.protoc.get().toString()\n    }\n    generateProtoTasks {\n        all().configureEach {\n            builtins {\n                named(\"java\") {\n                    option(\"lite\")\n                }\n                register(\"kotlin\") {\n                    option(\"lite\")\n                }\n            }\n        }\n    }\n}\n\ndependencies {\n    api(libs.protobuf.kotlin.lite)\n}\n"
  },
  {
    "path": "core/datastore-proto/src/main/proto/com/google/samples/apps/nowinandroid/data/dark_theme_config.proto",
    "content": "/*\n * Copyright (C) 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nsyntax = \"proto3\";\n\noption java_package = \"com.google.samples.apps.nowinandroid.core.datastore\";\noption java_multiple_files = true;\n\nenum DarkThemeConfigProto {\n  DARK_THEME_CONFIG_UNSPECIFIED = 0;\n  DARK_THEME_CONFIG_FOLLOW_SYSTEM = 1;\n  DARK_THEME_CONFIG_LIGHT = 2;\n  DARK_THEME_CONFIG_DARK = 3;\n}\n"
  },
  {
    "path": "core/datastore-proto/src/main/proto/com/google/samples/apps/nowinandroid/data/theme_brand.proto",
    "content": "/*\n * Copyright (C) 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nsyntax = \"proto3\";\n\noption java_package = \"com.google.samples.apps.nowinandroid.core.datastore\";\noption java_multiple_files = true;\n\nenum ThemeBrandProto {\n  THEME_BRAND_UNSPECIFIED = 0;\n  THEME_BRAND_DEFAULT = 1;\n  THEME_BRAND_ANDROID = 2;\n}\n"
  },
  {
    "path": "core/datastore-proto/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto",
    "content": "/*\n * Copyright (C) 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *      http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nsyntax = \"proto3\";\n\nimport \"com/google/samples/apps/nowinandroid/data/dark_theme_config.proto\";\nimport \"com/google/samples/apps/nowinandroid/data/theme_brand.proto\";\n\noption java_package = \"com.google.samples.apps.nowinandroid.core.datastore\";\noption java_multiple_files = true;\n\nmessage UserPreferences {\n    reserved 2;\n    repeated int32 deprecated_int_followed_topic_ids = 1;\n    int32 topicChangeListVersion = 3;\n    int32 authorChangeListVersion = 4;\n    int32 newsResourceChangeListVersion = 6;\n    repeated int32 deprecated_int_followed_author_ids = 7;\n    bool has_done_int_to_string_id_migration = 8;\n    repeated string deprecated_followed_topic_ids = 9;\n    repeated string deprecated_followed_author_ids = 10;\n    repeated string deprecated_bookmarked_news_resource_ids = 11;\n    bool has_done_list_to_map_migration = 12;\n\n    // Each map is used to store a set of string IDs. The bool has no meaning, but proto3 doesn't\n    // have a Set type so this is the closest we can get to a Set.\n    map<string, bool> followed_topic_ids = 13;\n    map<string, bool> followed_author_ids = 14;\n    map<string, bool> bookmarked_news_resource_ids = 15;\n    map<string, bool> viewed_news_resource_ids = 20;\n\n    ThemeBrandProto theme_brand = 16;\n    DarkThemeConfigProto dark_theme_config = 17;\n\n    bool should_hide_onboarding = 18;\n\n    bool use_dynamic_color = 19;\n\n    // NEXT AVAILABLE ID: 21\n}\n"
  },
  {
    "path": "core/datastore-test/.gitignore",
    "content": "/build"
  },
  {
    "path": "core/datastore-test/README.md",
    "content": "# `:core:datastore-test`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :core\n    direction TB\n    :core:common[common]:::jvm-library\n    :core:datastore[datastore]:::android-library\n    :core:datastore-proto[datastore-proto]:::jvm-library\n    :core:datastore-test[datastore-test]:::android-library\n    :core:model[model]:::jvm-library\n  end\n\n  :core:datastore -.-> :core:common\n  :core:datastore --> :core:datastore-proto\n  :core:datastore --> :core:model\n  :core:datastore-test -.-> :core:common\n  :core:datastore-test -.-> :core:datastore\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "core/datastore-test/build.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nplugins {\n    alias(libs.plugins.nowinandroid.android.library)\n    alias(libs.plugins.nowinandroid.hilt)\n}\n\nandroid {\n    namespace = \"com.google.samples.apps.nowinandroid.core.datastore.test\"\n}\n\ndependencies {\n    implementation(libs.hilt.android.testing)\n    implementation(projects.core.common)\n    implementation(projects.core.datastore)\n}\n"
  },
  {
    "path": "core/datastore-test/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<manifest />\n"
  },
  {
    "path": "core/datastore-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/datastore/test/InMemoryDataStore.kt",
    "content": "/*\n * Copyright 2024 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.datastore.test\n\nimport androidx.datastore.core.DataStore\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.updateAndGet\n\nclass InMemoryDataStore<T>(initialValue: T) : DataStore<T> {\n    override val data = MutableStateFlow(initialValue)\n    override suspend fun updateData(\n        transform: suspend (it: T) -> T,\n    ) = data.updateAndGet { transform(it) }\n}\n"
  },
  {
    "path": "core/datastore-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/datastore/test/TestDataStoreModule.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.datastore.test\n\nimport androidx.datastore.core.DataStore\nimport com.google.samples.apps.nowinandroid.core.datastore.UserPreferences\nimport com.google.samples.apps.nowinandroid.core.datastore.UserPreferencesSerializer\nimport com.google.samples.apps.nowinandroid.core.datastore.di.DataStoreModule\nimport dagger.Module\nimport dagger.Provides\nimport dagger.hilt.components.SingletonComponent\nimport dagger.hilt.testing.TestInstallIn\nimport javax.inject.Singleton\n\n@Module\n@TestInstallIn(\n    components = [SingletonComponent::class],\n    replaces = [DataStoreModule::class],\n)\ninternal object TestDataStoreModule {\n    @Provides\n    @Singleton\n    fun providesUserPreferencesDataStore(\n        serializer: UserPreferencesSerializer,\n    ): DataStore<UserPreferences> = InMemoryDataStore(serializer.defaultValue)\n}\n"
  },
  {
    "path": "core/designsystem/.gitignore",
    "content": "/build"
  },
  {
    "path": "core/designsystem/README.md",
    "content": "# `:core:designsystem`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :core\n    direction TB\n    :core:designsystem[designsystem]:::android-library\n  end\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "core/designsystem/build.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nplugins {\n    alias(libs.plugins.nowinandroid.android.library)\n    alias(libs.plugins.nowinandroid.android.library.compose)\n    alias(libs.plugins.nowinandroid.android.library.jacoco)\n    alias(libs.plugins.roborazzi)\n}\n\nandroid {\n    namespace = \"com.google.samples.apps.nowinandroid.core.designsystem\"\n    testOptions.unitTests.isIncludeAndroidResources = true\n}\n\ndependencies {\n    lintPublish(projects.lint)\n\n    api(libs.androidx.compose.foundation)\n    api(libs.androidx.compose.foundation.layout)\n    api(libs.androidx.compose.material.iconsExtended)\n    api(libs.androidx.compose.material3)\n    api(libs.androidx.compose.material3.adaptive)\n    api(libs.androidx.compose.material3.navigationSuite)\n    api(libs.androidx.compose.runtime)\n    api(libs.androidx.compose.ui.util)\n\n    implementation(libs.coil.kt.compose)\n\n    testImplementation(libs.androidx.compose.ui.test)\n    testImplementation(libs.androidx.compose.ui.testManifest)\n    \n    testImplementation(libs.hilt.android.testing)\n    testImplementation(libs.robolectric)\n    testImplementation(projects.core.screenshotTesting)\n}\n"
  },
  {
    "path": "core/designsystem/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<manifest />\n"
  },
  {
    "path": "core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Background.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem.component\n\nimport android.content.res.Configuration\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.material3.LocalAbsoluteTonalElevation\nimport androidx.compose.material3.Surface\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.CompositionLocalProvider\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.rememberUpdatedState\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.drawWithCache\nimport androidx.compose.ui.geometry.Offset\nimport androidx.compose.ui.graphics.Brush\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.tooling.preview.Preview\nimport androidx.compose.ui.unit.Dp\nimport androidx.compose.ui.unit.dp\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalBackgroundTheme\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\nimport kotlin.math.tan\n\n/**\n * The main background for the app.\n * Uses [LocalBackgroundTheme] to set the color and tonal elevation of a [Surface].\n *\n * @param modifier Modifier to be applied to the background.\n * @param content The background content.\n */\n@Composable\nfun NiaBackground(\n    modifier: Modifier = Modifier,\n    content: @Composable () -> Unit,\n) {\n    val color = LocalBackgroundTheme.current.color\n    val tonalElevation = LocalBackgroundTheme.current.tonalElevation\n    Surface(\n        color = if (color == Color.Unspecified) Color.Transparent else color,\n        tonalElevation = if (tonalElevation == Dp.Unspecified) 0.dp else tonalElevation,\n        modifier = modifier.fillMaxSize(),\n    ) {\n        CompositionLocalProvider(LocalAbsoluteTonalElevation provides 0.dp) {\n            content()\n        }\n    }\n}\n\n/**\n * A gradient background for select screens. Uses [LocalBackgroundTheme] to set the gradient colors\n * of a [Box] within a [Surface].\n *\n * @param modifier Modifier to be applied to the background.\n * @param gradientColors The gradient colors to be rendered.\n * @param content The background content.\n */\n@Composable\nfun NiaGradientBackground(\n    modifier: Modifier = Modifier,\n    gradientColors: GradientColors = LocalGradientColors.current,\n    content: @Composable () -> Unit,\n) {\n    val currentTopColor by rememberUpdatedState(gradientColors.top)\n    val currentBottomColor by rememberUpdatedState(gradientColors.bottom)\n    Surface(\n        color = if (gradientColors.container == Color.Unspecified) {\n            Color.Transparent\n        } else {\n            gradientColors.container\n        },\n        modifier = modifier.fillMaxSize(),\n    ) {\n        Box(\n            Modifier\n                .fillMaxSize()\n                .drawWithCache {\n                    // Compute the start and end coordinates such that the gradients are angled 11.06\n                    // degrees off the vertical axis\n                    val offset = size.height * tan(\n                        Math\n                            .toRadians(11.06)\n                            .toFloat(),\n                    )\n\n                    val start = Offset(size.width / 2 + offset / 2, 0f)\n                    val end = Offset(size.width / 2 - offset / 2, size.height)\n\n                    // Create the top gradient that fades out after the halfway point vertically\n                    val topGradient = Brush.linearGradient(\n                        0f to if (currentTopColor == Color.Unspecified) {\n                            Color.Transparent\n                        } else {\n                            currentTopColor\n                        },\n                        0.724f to Color.Transparent,\n                        start = start,\n                        end = end,\n                    )\n                    // Create the bottom gradient that fades in before the halfway point vertically\n                    val bottomGradient = Brush.linearGradient(\n                        0.2552f to Color.Transparent,\n                        1f to if (currentBottomColor == Color.Unspecified) {\n                            Color.Transparent\n                        } else {\n                            currentBottomColor\n                        },\n                        start = start,\n                        end = end,\n                    )\n\n                    onDrawBehind {\n                        // There is overlap here, so order is important\n                        drawRect(topGradient)\n                        drawRect(bottomGradient)\n                    }\n                },\n        ) {\n            content()\n        }\n    }\n}\n\n/**\n * Multipreview annotation that represents light and dark themes. Add this annotation to a\n * composable to render the both themes.\n */\n@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, name = \"Light theme\")\n@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, name = \"Dark theme\")\nannotation class ThemePreviews\n\n@ThemePreviews\n@Composable\nfun BackgroundDefault() {\n    NiaTheme(disableDynamicTheming = true) {\n        NiaBackground(Modifier.size(100.dp), content = {})\n    }\n}\n\n@ThemePreviews\n@Composable\nfun BackgroundDynamic() {\n    NiaTheme(disableDynamicTheming = false) {\n        NiaBackground(Modifier.size(100.dp), content = {})\n    }\n}\n\n@ThemePreviews\n@Composable\nfun BackgroundAndroid() {\n    NiaTheme(androidTheme = true) {\n        NiaBackground(Modifier.size(100.dp), content = {})\n    }\n}\n\n@ThemePreviews\n@Composable\nfun GradientBackgroundDefault() {\n    NiaTheme(disableDynamicTheming = true) {\n        NiaGradientBackground(Modifier.size(100.dp), content = {})\n    }\n}\n\n@ThemePreviews\n@Composable\nfun GradientBackgroundDynamic() {\n    NiaTheme(disableDynamicTheming = false) {\n        NiaGradientBackground(Modifier.size(100.dp), content = {})\n    }\n}\n\n@ThemePreviews\n@Composable\nfun GradientBackgroundAndroid() {\n    NiaTheme(androidTheme = true) {\n        NiaGradientBackground(Modifier.size(100.dp), content = {})\n    }\n}\n"
  },
  {
    "path": "core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Button.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem.component\n\nimport androidx.compose.foundation.BorderStroke\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.PaddingValues\nimport androidx.compose.foundation.layout.RowScope\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.layout.sizeIn\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.ButtonDefaults\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.OutlinedButton\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TextButton\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.unit.dp\nimport com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\n\n/**\n * Now in Android filled button with generic content slot. Wraps Material 3 [Button].\n *\n * @param onClick Will be called when the user clicks the button.\n * @param modifier Modifier to be applied to the button.\n * @param enabled Controls the enabled state of the button. When `false`, this button will not be\n * clickable and will appear disabled to accessibility services.\n * @param contentPadding The spacing values to apply internally between the container and the\n * content.\n * @param content The button content.\n */\n@Composable\nfun NiaButton(\n    onClick: () -> Unit,\n    modifier: Modifier = Modifier,\n    enabled: Boolean = true,\n    contentPadding: PaddingValues = ButtonDefaults.ContentPadding,\n    content: @Composable RowScope.() -> Unit,\n) {\n    Button(\n        onClick = onClick,\n        modifier = modifier,\n        enabled = enabled,\n        colors = ButtonDefaults.buttonColors(\n            containerColor = MaterialTheme.colorScheme.onBackground,\n        ),\n        contentPadding = contentPadding,\n        content = content,\n    )\n}\n\n/**\n * Now in Android filled button with text and icon content slots.\n *\n * @param onClick Will be called when the user clicks the button.\n * @param modifier Modifier to be applied to the button.\n * @param enabled Controls the enabled state of the button. When `false`, this button will not be\n * clickable and will appear disabled to accessibility services.\n * @param text The button text label content.\n * @param leadingIcon The button leading icon content. Pass `null` here for no leading icon.\n */\n@Composable\nfun NiaButton(\n    onClick: () -> Unit,\n    modifier: Modifier = Modifier,\n    enabled: Boolean = true,\n    text: @Composable () -> Unit,\n    leadingIcon: @Composable (() -> Unit)? = null,\n) {\n    NiaButton(\n        onClick = onClick,\n        modifier = modifier,\n        enabled = enabled,\n        contentPadding = if (leadingIcon != null) {\n            ButtonDefaults.ButtonWithIconContentPadding\n        } else {\n            ButtonDefaults.ContentPadding\n        },\n    ) {\n        NiaButtonContent(\n            text = text,\n            leadingIcon = leadingIcon,\n        )\n    }\n}\n\n/**\n * Now in Android outlined button with generic content slot. Wraps Material 3 [OutlinedButton].\n *\n * @param onClick Will be called when the user clicks the button.\n * @param modifier Modifier to be applied to the button.\n * @param enabled Controls the enabled state of the button. When `false`, this button will not be\n * clickable and will appear disabled to accessibility services.\n * @param contentPadding The spacing values to apply internally between the container and the\n * content.\n * @param content The button content.\n */\n@Composable\nfun NiaOutlinedButton(\n    onClick: () -> Unit,\n    modifier: Modifier = Modifier,\n    enabled: Boolean = true,\n    contentPadding: PaddingValues = ButtonDefaults.ContentPadding,\n    content: @Composable RowScope.() -> Unit,\n) {\n    OutlinedButton(\n        onClick = onClick,\n        modifier = modifier,\n        enabled = enabled,\n        colors = ButtonDefaults.outlinedButtonColors(\n            contentColor = MaterialTheme.colorScheme.onBackground,\n        ),\n        border = BorderStroke(\n            width = NiaButtonDefaults.OutlinedButtonBorderWidth,\n            color = if (enabled) {\n                MaterialTheme.colorScheme.outline\n            } else {\n                MaterialTheme.colorScheme.onSurface.copy(\n                    alpha = NiaButtonDefaults.DISABLED_OUTLINED_BUTTON_BORDER_ALPHA,\n                )\n            },\n        ),\n        contentPadding = contentPadding,\n        content = content,\n    )\n}\n\n/**\n * Now in Android outlined button with text and icon content slots.\n *\n * @param onClick Will be called when the user clicks the button.\n * @param modifier Modifier to be applied to the button.\n * @param enabled Controls the enabled state of the button. When `false`, this button will not be\n * clickable and will appear disabled to accessibility services.\n * @param text The button text label content.\n * @param leadingIcon The button leading icon content. Pass `null` here for no leading icon.\n */\n@Composable\nfun NiaOutlinedButton(\n    onClick: () -> Unit,\n    modifier: Modifier = Modifier,\n    enabled: Boolean = true,\n    text: @Composable () -> Unit,\n    leadingIcon: @Composable (() -> Unit)? = null,\n) {\n    NiaOutlinedButton(\n        onClick = onClick,\n        modifier = modifier,\n        enabled = enabled,\n        contentPadding = if (leadingIcon != null) {\n            ButtonDefaults.ButtonWithIconContentPadding\n        } else {\n            ButtonDefaults.ContentPadding\n        },\n    ) {\n        NiaButtonContent(\n            text = text,\n            leadingIcon = leadingIcon,\n        )\n    }\n}\n\n/**\n * Now in Android text button with generic content slot. Wraps Material 3 [TextButton].\n *\n * @param onClick Will be called when the user clicks the button.\n * @param modifier Modifier to be applied to the button.\n * @param enabled Controls the enabled state of the button. When `false`, this button will not be\n * clickable and will appear disabled to accessibility services.\n * @param content The button content.\n */\n@Composable\nfun NiaTextButton(\n    onClick: () -> Unit,\n    modifier: Modifier = Modifier,\n    enabled: Boolean = true,\n    content: @Composable RowScope.() -> Unit,\n) {\n    TextButton(\n        onClick = onClick,\n        modifier = modifier,\n        enabled = enabled,\n        colors = ButtonDefaults.textButtonColors(\n            contentColor = MaterialTheme.colorScheme.onBackground,\n        ),\n        content = content,\n    )\n}\n\n/**\n * Now in Android text button with text and icon content slots.\n *\n * @param onClick Will be called when the user clicks the button.\n * @param modifier Modifier to be applied to the button.\n * @param enabled Controls the enabled state of the button. When `false`, this button will not be\n * clickable and will appear disabled to accessibility services.\n * @param text The button text label content.\n * @param leadingIcon The button leading icon content. Pass `null` here for no leading icon.\n */\n@Composable\nfun NiaTextButton(\n    onClick: () -> Unit,\n    modifier: Modifier = Modifier,\n    enabled: Boolean = true,\n    text: @Composable () -> Unit,\n    leadingIcon: @Composable (() -> Unit)? = null,\n) {\n    NiaTextButton(\n        onClick = onClick,\n        modifier = modifier,\n        enabled = enabled,\n    ) {\n        NiaButtonContent(\n            text = text,\n            leadingIcon = leadingIcon,\n        )\n    }\n}\n\n/**\n * Internal Now in Android button content layout for arranging the text label and leading icon.\n *\n * @param text The button text label content.\n * @param leadingIcon The button leading icon content. Default is `null` for no leading icon.Ï\n */\n@Composable\nprivate fun NiaButtonContent(\n    text: @Composable () -> Unit,\n    leadingIcon: @Composable (() -> Unit)? = null,\n) {\n    if (leadingIcon != null) {\n        Box(Modifier.sizeIn(maxHeight = ButtonDefaults.IconSize)) {\n            leadingIcon()\n        }\n    }\n    Box(\n        Modifier\n            .padding(\n                start = if (leadingIcon != null) {\n                    ButtonDefaults.IconSpacing\n                } else {\n                    0.dp\n                },\n            ),\n    ) {\n        text()\n    }\n}\n\n@ThemePreviews\n@Composable\nfun NiaButtonPreview() {\n    NiaTheme {\n        NiaBackground(modifier = Modifier.size(150.dp, 50.dp)) {\n            NiaButton(onClick = {}, text = { Text(\"Test button\") })\n        }\n    }\n}\n\n@ThemePreviews\n@Composable\nfun NiaOutlinedButtonPreview() {\n    NiaTheme {\n        NiaBackground(modifier = Modifier.size(150.dp, 50.dp)) {\n            NiaOutlinedButton(onClick = {}, text = { Text(\"Test button\") })\n        }\n    }\n}\n\n@ThemePreviews\n@Composable\nfun NiaButtonLeadingIconPreview() {\n    NiaTheme {\n        NiaBackground(modifier = Modifier.size(150.dp, 50.dp)) {\n            NiaButton(\n                onClick = {},\n                text = { Text(\"Test button\") },\n                leadingIcon = { Icon(imageVector = NiaIcons.Add, contentDescription = null) },\n            )\n        }\n    }\n}\n\n/**\n * Now in Android button default values.\n */\nobject NiaButtonDefaults {\n    // TODO: File bug\n    // OutlinedButton border color doesn't respect disabled state by default\n    const val DISABLED_OUTLINED_BUTTON_BORDER_ALPHA = 0.12f\n\n    // TODO: File bug\n    // OutlinedButton default border width isn't exposed via ButtonDefaults\n    val OutlinedButtonBorderWidth = 1.dp\n}\n"
  },
  {
    "path": "core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Chip.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem.component\n\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.material3.FilterChip\nimport androidx.compose.material3.FilterChipDefaults\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.ProvideTextStyle\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.unit.dp\nimport com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\n\n/**\n * Now in Android filter chip with included leading checked icon as well as text content slot.\n *\n * @param selected Whether the chip is currently checked.\n * @param onSelectedChange Called when the user clicks the chip and toggles checked.\n * @param modifier Modifier to be applied to the chip.\n * @param enabled Controls the enabled state of the chip. When `false`, this chip will not be\n * clickable and will appear disabled to accessibility services.\n * @param label The text label content.\n */\n@Composable\nfun NiaFilterChip(\n    selected: Boolean,\n    onSelectedChange: (Boolean) -> Unit,\n    modifier: Modifier = Modifier,\n    enabled: Boolean = true,\n    label: @Composable () -> Unit,\n) {\n    FilterChip(\n        selected = selected,\n        onClick = { onSelectedChange(!selected) },\n        label = {\n            ProvideTextStyle(value = MaterialTheme.typography.labelSmall) {\n                label()\n            }\n        },\n        modifier = modifier,\n        enabled = enabled,\n        leadingIcon = if (selected) {\n            {\n                Icon(\n                    imageVector = NiaIcons.Check,\n                    contentDescription = null,\n                )\n            }\n        } else {\n            null\n        },\n        shape = CircleShape,\n        border = FilterChipDefaults.filterChipBorder(\n            enabled = enabled,\n            selected = selected,\n            borderColor = MaterialTheme.colorScheme.onBackground,\n            selectedBorderColor = MaterialTheme.colorScheme.onBackground,\n            disabledBorderColor = MaterialTheme.colorScheme.onBackground.copy(\n                alpha = NiaChipDefaults.DISABLED_CHIP_CONTENT_ALPHA,\n            ),\n            disabledSelectedBorderColor = MaterialTheme.colorScheme.onBackground.copy(\n                alpha = NiaChipDefaults.DISABLED_CHIP_CONTENT_ALPHA,\n            ),\n            selectedBorderWidth = NiaChipDefaults.ChipBorderWidth,\n        ),\n        colors = FilterChipDefaults.filterChipColors(\n            labelColor = MaterialTheme.colorScheme.onBackground,\n            iconColor = MaterialTheme.colorScheme.onBackground,\n            disabledContainerColor = if (selected) {\n                MaterialTheme.colorScheme.onBackground.copy(\n                    alpha = NiaChipDefaults.DISABLED_CHIP_CONTAINER_ALPHA,\n                )\n            } else {\n                Color.Transparent\n            },\n            disabledLabelColor = MaterialTheme.colorScheme.onBackground.copy(\n                alpha = NiaChipDefaults.DISABLED_CHIP_CONTENT_ALPHA,\n            ),\n            disabledLeadingIconColor = MaterialTheme.colorScheme.onBackground.copy(\n                alpha = NiaChipDefaults.DISABLED_CHIP_CONTENT_ALPHA,\n            ),\n            selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,\n            selectedLabelColor = MaterialTheme.colorScheme.onBackground,\n            selectedLeadingIconColor = MaterialTheme.colorScheme.onBackground,\n        ),\n    )\n}\n\n@ThemePreviews\n@Composable\nfun ChipPreview() {\n    NiaTheme {\n        NiaBackground(modifier = Modifier.size(80.dp, 20.dp)) {\n            NiaFilterChip(selected = true, onSelectedChange = {}) {\n                Text(\"Chip\")\n            }\n        }\n    }\n}\n\n/**\n * Now in Android chip default values.\n */\nobject NiaChipDefaults {\n    // TODO: File bug\n    // FilterChip default values aren't exposed via FilterChipDefaults\n    const val DISABLED_CHIP_CONTAINER_ALPHA = 0.12f\n    const val DISABLED_CHIP_CONTENT_ALPHA = 0.38f\n    val ChipBorderWidth = 1.dp\n}\n"
  },
  {
    "path": "core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/DynamicAsyncImage.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem.component\n\nimport androidx.compose.foundation.Image\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.material3.CircularProgressIndicator\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.Color.Companion.Unspecified\nimport androidx.compose.ui.graphics.ColorFilter\nimport androidx.compose.ui.graphics.painter.Painter\nimport androidx.compose.ui.layout.ContentScale\nimport androidx.compose.ui.platform.LocalInspectionMode\nimport androidx.compose.ui.res.painterResource\nimport androidx.compose.ui.unit.dp\nimport coil.compose.AsyncImage\nimport coil.compose.AsyncImagePainter.State.Error\nimport coil.compose.AsyncImagePainter.State.Loading\nimport coil.compose.rememberAsyncImagePainter\nimport com.google.samples.apps.nowinandroid.core.designsystem.R\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalTintTheme\n\n/**\n * A wrapper around [AsyncImage] which determines the colorFilter based on the theme\n */\n@Composable\nfun DynamicAsyncImage(\n    imageUrl: String,\n    contentDescription: String?,\n    modifier: Modifier = Modifier,\n    placeholder: Painter = painterResource(R.drawable.core_designsystem_ic_placeholder_default),\n) {\n    val iconTint = LocalTintTheme.current.iconTint\n    var isLoading by remember { mutableStateOf(true) }\n    var isError by remember { mutableStateOf(false) }\n    val imageLoader = rememberAsyncImagePainter(\n        model = imageUrl,\n        onState = { state ->\n            isLoading = state is Loading\n            isError = state is Error\n        },\n    )\n    val isLocalInspection = LocalInspectionMode.current\n    Box(\n        modifier = modifier,\n        contentAlignment = Alignment.Center,\n    ) {\n        if (isLoading && !isLocalInspection) {\n            // Display a progress bar while loading\n            CircularProgressIndicator(\n                modifier = Modifier\n                    .align(Alignment.Center)\n                    .size(80.dp),\n                color = MaterialTheme.colorScheme.tertiary,\n            )\n        }\n        Image(\n            contentScale = ContentScale.Crop,\n            painter = if (isError.not() && !isLocalInspection) imageLoader else placeholder,\n            contentDescription = contentDescription,\n            colorFilter = if (iconTint != Unspecified) ColorFilter.tint(iconTint) else null,\n        )\n    }\n}\n"
  },
  {
    "path": "core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/IconButton.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem.component\n\nimport androidx.compose.material3.FilledIconToggleButton\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.IconButtonDefaults\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.Color\nimport com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\n\n/**\n * Now in Android toggle button with icon and checked icon content slots. Wraps Material 3\n * [IconButton].\n *\n * @param checked Whether the toggle button is currently checked.\n * @param onCheckedChange Called when the user clicks the toggle button and toggles checked.\n * @param modifier Modifier to be applied to the toggle button.\n * @param enabled Controls the enabled state of the toggle button. When `false`, this toggle button\n * will not be clickable and will appear disabled to accessibility services.\n * @param icon The icon content to show when unchecked.\n * @param checkedIcon The icon content to show when checked.\n */\n@Composable\nfun NiaIconToggleButton(\n    checked: Boolean,\n    onCheckedChange: (Boolean) -> Unit,\n    modifier: Modifier = Modifier,\n    enabled: Boolean = true,\n    icon: @Composable () -> Unit,\n    checkedIcon: @Composable () -> Unit = icon,\n) {\n    // TODO: File bug\n    // Can't use regular IconToggleButton as it doesn't include a shape (appears square)\n    FilledIconToggleButton(\n        checked = checked,\n        onCheckedChange = onCheckedChange,\n        modifier = modifier,\n        enabled = enabled,\n        colors = IconButtonDefaults.iconToggleButtonColors(\n            checkedContainerColor = MaterialTheme.colorScheme.primaryContainer,\n            checkedContentColor = MaterialTheme.colorScheme.onPrimaryContainer,\n            disabledContainerColor = if (checked) {\n                MaterialTheme.colorScheme.onBackground.copy(\n                    alpha = NiaIconButtonDefaults.DISABLED_ICON_BUTTON_CONTAINER_ALPHA,\n                )\n            } else {\n                Color.Transparent\n            },\n        ),\n    ) {\n        if (checked) checkedIcon() else icon()\n    }\n}\n\n@ThemePreviews\n@Composable\nfun IconButtonPreview() {\n    NiaTheme {\n        NiaIconToggleButton(\n            checked = true,\n            onCheckedChange = { },\n            icon = {\n                Icon(\n                    imageVector = NiaIcons.BookmarkBorder,\n                    contentDescription = null,\n                )\n            },\n            checkedIcon = {\n                Icon(\n                    imageVector = NiaIcons.Bookmark,\n                    contentDescription = null,\n                )\n            },\n        )\n    }\n}\n\n@ThemePreviews\n@Composable\nfun IconButtonPreviewUnchecked() {\n    NiaTheme {\n        NiaIconToggleButton(\n            checked = false,\n            onCheckedChange = { },\n            icon = {\n                Icon(\n                    imageVector = NiaIcons.BookmarkBorder,\n                    contentDescription = null,\n                )\n            },\n            checkedIcon = {\n                Icon(\n                    imageVector = NiaIcons.Bookmark,\n                    contentDescription = null,\n                )\n            },\n        )\n    }\n}\n\n/**\n * Now in Android icon button default values.\n */\nobject NiaIconButtonDefaults {\n    // TODO: File bug\n    // IconToggleButton disabled container alpha not exposed by IconButtonDefaults\n    const val DISABLED_ICON_BUTTON_CONTAINER_ALPHA = 0.12f\n}\n"
  },
  {
    "path": "core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/LoadingWheel.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem.component\n\nimport androidx.compose.animation.animateColor\nimport androidx.compose.animation.core.Animatable\nimport androidx.compose.animation.core.FastOutSlowInEasing\nimport androidx.compose.animation.core.LinearEasing\nimport androidx.compose.animation.core.RepeatMode\nimport androidx.compose.animation.core.StartOffset\nimport androidx.compose.animation.core.animateFloat\nimport androidx.compose.animation.core.infiniteRepeatable\nimport androidx.compose.animation.core.keyframes\nimport androidx.compose.animation.core.rememberInfiniteTransition\nimport androidx.compose.animation.core.tween\nimport androidx.compose.foundation.Canvas\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Surface\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.remember\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.geometry.Offset\nimport androidx.compose.ui.graphics.StrokeCap\nimport androidx.compose.ui.graphics.drawscope.rotate\nimport androidx.compose.ui.graphics.graphicsLayer\nimport androidx.compose.ui.platform.LocalInspectionMode\nimport androidx.compose.ui.platform.testTag\nimport androidx.compose.ui.semantics.contentDescription\nimport androidx.compose.ui.semantics.semantics\nimport androidx.compose.ui.unit.dp\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\nimport kotlinx.coroutines.launch\n\n@Composable\nfun NiaLoadingWheel(\n    contentDesc: String,\n    modifier: Modifier = Modifier,\n) {\n    val infiniteTransition = rememberInfiniteTransition(label = \"wheel transition\")\n\n    // Specifies the float animation for slowly drawing out the lines on entering\n    val startValue = if (LocalInspectionMode.current) 0F else 1F\n    val floatAnimValues = (0 until NUM_OF_LINES).map { remember { Animatable(startValue) } }\n    LaunchedEffect(floatAnimValues) {\n        (0 until NUM_OF_LINES).map { index ->\n            launch {\n                floatAnimValues[index].animateTo(\n                    targetValue = 0F,\n                    animationSpec = tween(\n                        durationMillis = 100,\n                        easing = FastOutSlowInEasing,\n                        delayMillis = 40 * index,\n                    ),\n                )\n            }\n        }\n    }\n\n    // Specifies the rotation animation of the entire Canvas composable\n    val rotationAnim by infiniteTransition.animateFloat(\n        initialValue = 0F,\n        targetValue = 360F,\n        animationSpec = infiniteRepeatable(\n            animation = tween(durationMillis = ROTATION_TIME, easing = LinearEasing),\n        ),\n        label = \"wheel rotation animation\",\n    )\n\n    // Specifies the color animation for the base-to-progress line color change\n    val baseLineColor = MaterialTheme.colorScheme.onBackground\n    val progressLineColor = MaterialTheme.colorScheme.inversePrimary\n\n    val colorAnimValues = (0 until NUM_OF_LINES).map { index ->\n        infiniteTransition.animateColor(\n            initialValue = baseLineColor,\n            targetValue = baseLineColor,\n            animationSpec = infiniteRepeatable(\n                animation = keyframes {\n                    durationMillis = ROTATION_TIME / 2\n                    progressLineColor at ROTATION_TIME / NUM_OF_LINES / 2 using LinearEasing\n                    baseLineColor at ROTATION_TIME / NUM_OF_LINES using LinearEasing\n                },\n                repeatMode = RepeatMode.Restart,\n                initialStartOffset = StartOffset(ROTATION_TIME / NUM_OF_LINES / 2 * index),\n            ),\n            label = \"wheel color animation\",\n        )\n    }\n\n    // Draws out the LoadingWheel Canvas composable and sets the animations\n    Canvas(\n        modifier = modifier\n            .size(48.dp)\n            .padding(8.dp)\n            .graphicsLayer { rotationZ = rotationAnim }\n            .semantics { contentDescription = contentDesc }\n            .testTag(\"loadingWheel\"),\n    ) {\n        repeat(NUM_OF_LINES) { index ->\n            rotate(degrees = index * 30f) {\n                drawLine(\n                    color = colorAnimValues[index].value,\n                    // Animates the initially drawn 1 pixel alpha from 0 to 1\n                    alpha = if (floatAnimValues[index].value < 1f) 1f else 0f,\n                    strokeWidth = 4F,\n                    cap = StrokeCap.Round,\n                    start = Offset(size.width / 2, size.height / 4),\n                    end = Offset(size.width / 2, floatAnimValues[index].value * size.height / 4),\n                )\n            }\n        }\n    }\n}\n\n@Composable\nfun NiaOverlayLoadingWheel(\n    contentDesc: String,\n    modifier: Modifier = Modifier,\n) {\n    Surface(\n        shape = RoundedCornerShape(60.dp),\n        shadowElevation = 8.dp,\n        color = MaterialTheme.colorScheme.surface.copy(alpha = 0.83f),\n        modifier = modifier\n            .size(60.dp),\n    ) {\n        NiaLoadingWheel(\n            contentDesc = contentDesc,\n        )\n    }\n}\n\n@ThemePreviews\n@Composable\nfun NiaLoadingWheelPreview() {\n    NiaTheme {\n        Surface {\n            NiaLoadingWheel(contentDesc = \"LoadingWheel\")\n        }\n    }\n}\n\n@ThemePreviews\n@Composable\nfun NiaOverlayLoadingWheelPreview() {\n    NiaTheme {\n        Surface {\n            NiaOverlayLoadingWheel(contentDesc = \"LoadingWheel\")\n        }\n    }\n}\n\nprivate const val ROTATION_TIME = 12000\nprivate const val NUM_OF_LINES = 12\n"
  },
  {
    "path": "core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Navigation.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem.component\n\nimport androidx.compose.foundation.layout.ColumnScope\nimport androidx.compose.foundation.layout.RowScope\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.NavigationBar\nimport androidx.compose.material3.NavigationBarItem\nimport androidx.compose.material3.NavigationBarItemDefaults\nimport androidx.compose.material3.NavigationDrawerItemDefaults\nimport androidx.compose.material3.NavigationRail\nimport androidx.compose.material3.NavigationRailItem\nimport androidx.compose.material3.NavigationRailItemDefaults\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.adaptive.WindowAdaptiveInfo\nimport androidx.compose.material3.adaptive.currentWindowAdaptiveInfo\nimport androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults\nimport androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItemColors\nimport androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold\nimport androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults\nimport androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScope\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.unit.dp\nimport com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\n\n/**\n * Now in Android navigation bar item with icon and label content slots. Wraps Material 3\n * [NavigationBarItem].\n *\n * @param selected Whether this item is selected.\n * @param onClick The callback to be invoked when this item is selected.\n * @param icon The item icon content.\n * @param modifier Modifier to be applied to this item.\n * @param selectedIcon The item icon content when selected.\n * @param enabled controls the enabled state of this item. When `false`, this item will not be\n * clickable and will appear disabled to accessibility services.\n * @param label The item text label content.\n * @param alwaysShowLabel Whether to always show the label for this item. If false, the label will\n * only be shown when this item is selected.\n */\n@Composable\nfun RowScope.NiaNavigationBarItem(\n    selected: Boolean,\n    onClick: () -> Unit,\n    modifier: Modifier = Modifier,\n    enabled: Boolean = true,\n    alwaysShowLabel: Boolean = true,\n    icon: @Composable () -> Unit,\n    selectedIcon: @Composable () -> Unit = icon,\n    label: @Composable (() -> Unit)? = null,\n) {\n    NavigationBarItem(\n        selected = selected,\n        onClick = onClick,\n        icon = if (selected) selectedIcon else icon,\n        modifier = modifier,\n        enabled = enabled,\n        label = label,\n        alwaysShowLabel = alwaysShowLabel,\n        colors = NavigationBarItemDefaults.colors(\n            selectedIconColor = NiaNavigationDefaults.navigationSelectedItemColor(),\n            unselectedIconColor = NiaNavigationDefaults.navigationContentColor(),\n            selectedTextColor = NiaNavigationDefaults.navigationSelectedItemColor(),\n            unselectedTextColor = NiaNavigationDefaults.navigationContentColor(),\n            indicatorColor = NiaNavigationDefaults.navigationIndicatorColor(),\n        ),\n    )\n}\n\n/**\n * Now in Android navigation bar with content slot. Wraps Material 3 [NavigationBar].\n *\n * @param modifier Modifier to be applied to the navigation bar.\n * @param content Destinations inside the navigation bar. This should contain multiple\n * [NavigationBarItem]s.\n */\n@Composable\nfun NiaNavigationBar(\n    modifier: Modifier = Modifier,\n    content: @Composable RowScope.() -> Unit,\n) {\n    NavigationBar(\n        modifier = modifier,\n        contentColor = NiaNavigationDefaults.navigationContentColor(),\n        tonalElevation = 0.dp,\n        content = content,\n    )\n}\n\n/**\n * Now in Android navigation rail item with icon and label content slots. Wraps Material 3\n * [NavigationRailItem].\n *\n * @param selected Whether this item is selected.\n * @param onClick The callback to be invoked when this item is selected.\n * @param icon The item icon content.\n * @param modifier Modifier to be applied to this item.\n * @param selectedIcon The item icon content when selected.\n * @param enabled controls the enabled state of this item. When `false`, this item will not be\n * clickable and will appear disabled to accessibility services.\n * @param label The item text label content.\n * @param alwaysShowLabel Whether to always show the label for this item. If false, the label will\n * only be shown when this item is selected.\n */\n@Composable\nfun NiaNavigationRailItem(\n    selected: Boolean,\n    onClick: () -> Unit,\n    modifier: Modifier = Modifier,\n    enabled: Boolean = true,\n    alwaysShowLabel: Boolean = true,\n    icon: @Composable () -> Unit,\n    selectedIcon: @Composable () -> Unit = icon,\n    label: @Composable (() -> Unit)? = null,\n) {\n    NavigationRailItem(\n        selected = selected,\n        onClick = onClick,\n        icon = if (selected) selectedIcon else icon,\n        modifier = modifier,\n        enabled = enabled,\n        label = label,\n        alwaysShowLabel = alwaysShowLabel,\n        colors = NavigationRailItemDefaults.colors(\n            selectedIconColor = NiaNavigationDefaults.navigationSelectedItemColor(),\n            unselectedIconColor = NiaNavigationDefaults.navigationContentColor(),\n            selectedTextColor = NiaNavigationDefaults.navigationSelectedItemColor(),\n            unselectedTextColor = NiaNavigationDefaults.navigationContentColor(),\n            indicatorColor = NiaNavigationDefaults.navigationIndicatorColor(),\n        ),\n    )\n}\n\n/**\n * Now in Android navigation rail with header and content slots. Wraps Material 3 [NavigationRail].\n *\n * @param modifier Modifier to be applied to the navigation rail.\n * @param header Optional header that may hold a floating action button or a logo.\n * @param content Destinations inside the navigation rail. This should contain multiple\n * [NavigationRailItem]s.\n */\n@Composable\nfun NiaNavigationRail(\n    modifier: Modifier = Modifier,\n    header: @Composable (ColumnScope.() -> Unit)? = null,\n    content: @Composable ColumnScope.() -> Unit,\n) {\n    NavigationRail(\n        modifier = modifier,\n        containerColor = Color.Transparent,\n        contentColor = NiaNavigationDefaults.navigationContentColor(),\n        header = header,\n        content = content,\n    )\n}\n\n/**\n * Now in Android navigation suite scaffold with item and content slots.\n * Wraps Material 3 [NavigationSuiteScaffold].\n *\n * @param modifier Modifier to be applied to the navigation suite scaffold.\n * @param navigationSuiteItems A slot to display multiple items via [NiaNavigationSuiteScope].\n * @param windowAdaptiveInfo The window adaptive info.\n * @param content The app content inside the scaffold.\n */\n@Composable\nfun NiaNavigationSuiteScaffold(\n    navigationSuiteItems: NiaNavigationSuiteScope.() -> Unit,\n    modifier: Modifier = Modifier,\n    windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(),\n    content: @Composable () -> Unit,\n) {\n    val layoutType = NavigationSuiteScaffoldDefaults\n        .calculateFromAdaptiveInfo(windowAdaptiveInfo)\n    val navigationSuiteItemColors = NavigationSuiteItemColors(\n        navigationBarItemColors = NavigationBarItemDefaults.colors(\n            selectedIconColor = NiaNavigationDefaults.navigationSelectedItemColor(),\n            unselectedIconColor = NiaNavigationDefaults.navigationContentColor(),\n            selectedTextColor = NiaNavigationDefaults.navigationSelectedItemColor(),\n            unselectedTextColor = NiaNavigationDefaults.navigationContentColor(),\n            indicatorColor = NiaNavigationDefaults.navigationIndicatorColor(),\n        ),\n        navigationRailItemColors = NavigationRailItemDefaults.colors(\n            selectedIconColor = NiaNavigationDefaults.navigationSelectedItemColor(),\n            unselectedIconColor = NiaNavigationDefaults.navigationContentColor(),\n            selectedTextColor = NiaNavigationDefaults.navigationSelectedItemColor(),\n            unselectedTextColor = NiaNavigationDefaults.navigationContentColor(),\n            indicatorColor = NiaNavigationDefaults.navigationIndicatorColor(),\n        ),\n        navigationDrawerItemColors = NavigationDrawerItemDefaults.colors(\n            selectedIconColor = NiaNavigationDefaults.navigationSelectedItemColor(),\n            unselectedIconColor = NiaNavigationDefaults.navigationContentColor(),\n            selectedTextColor = NiaNavigationDefaults.navigationSelectedItemColor(),\n            unselectedTextColor = NiaNavigationDefaults.navigationContentColor(),\n        ),\n    )\n\n    NavigationSuiteScaffold(\n        navigationSuiteItems = {\n            NiaNavigationSuiteScope(\n                navigationSuiteScope = this,\n                navigationSuiteItemColors = navigationSuiteItemColors,\n            ).run(navigationSuiteItems)\n        },\n        layoutType = layoutType,\n        containerColor = Color.Transparent,\n        navigationSuiteColors = NavigationSuiteDefaults.colors(\n            navigationBarContentColor = NiaNavigationDefaults.navigationContentColor(),\n            navigationRailContainerColor = Color.Transparent,\n        ),\n        modifier = modifier,\n    ) {\n        content()\n    }\n}\n\n/**\n * A wrapper around [NavigationSuiteScope] to declare navigation items.\n */\nclass NiaNavigationSuiteScope internal constructor(\n    private val navigationSuiteScope: NavigationSuiteScope,\n    private val navigationSuiteItemColors: NavigationSuiteItemColors,\n) {\n    fun item(\n        selected: Boolean,\n        onClick: () -> Unit,\n        modifier: Modifier = Modifier,\n        icon: @Composable () -> Unit,\n        selectedIcon: @Composable () -> Unit = icon,\n        label: @Composable (() -> Unit)? = null,\n    ) = navigationSuiteScope.item(\n        selected = selected,\n        onClick = onClick,\n        icon = {\n            if (selected) {\n                selectedIcon()\n            } else {\n                icon()\n            }\n        },\n        label = label,\n        colors = navigationSuiteItemColors,\n        modifier = modifier,\n    )\n}\n\n@ThemePreviews\n@Composable\nfun NiaNavigationBarPreview() {\n    val items = listOf(\"For you\", \"Saved\", \"Interests\")\n    val icons = listOf(\n        NiaIcons.UpcomingBorder,\n        NiaIcons.BookmarksBorder,\n        NiaIcons.Grid3x3,\n    )\n    val selectedIcons = listOf(\n        NiaIcons.Upcoming,\n        NiaIcons.Bookmarks,\n        NiaIcons.Grid3x3,\n    )\n\n    NiaTheme {\n        NiaNavigationBar {\n            items.forEachIndexed { index, item ->\n                NiaNavigationBarItem(\n                    icon = {\n                        Icon(\n                            imageVector = icons[index],\n                            contentDescription = item,\n                        )\n                    },\n                    selectedIcon = {\n                        Icon(\n                            imageVector = selectedIcons[index],\n                            contentDescription = item,\n                        )\n                    },\n                    label = { Text(item) },\n                    selected = index == 0,\n                    onClick = { },\n                )\n            }\n        }\n    }\n}\n\n@ThemePreviews\n@Composable\nfun NiaNavigationRailPreview() {\n    val items = listOf(\"For you\", \"Saved\", \"Interests\")\n    val icons = listOf(\n        NiaIcons.UpcomingBorder,\n        NiaIcons.BookmarksBorder,\n        NiaIcons.Grid3x3,\n    )\n    val selectedIcons = listOf(\n        NiaIcons.Upcoming,\n        NiaIcons.Bookmarks,\n        NiaIcons.Grid3x3,\n    )\n\n    NiaTheme {\n        NiaNavigationRail {\n            items.forEachIndexed { index, item ->\n                NiaNavigationRailItem(\n                    icon = {\n                        Icon(\n                            imageVector = icons[index],\n                            contentDescription = item,\n                        )\n                    },\n                    selectedIcon = {\n                        Icon(\n                            imageVector = selectedIcons[index],\n                            contentDescription = item,\n                        )\n                    },\n                    label = { Text(item) },\n                    selected = index == 0,\n                    onClick = { },\n                )\n            }\n        }\n    }\n}\n\n/**\n * Now in Android navigation default values.\n */\nobject NiaNavigationDefaults {\n    @Composable\n    fun navigationContentColor() = MaterialTheme.colorScheme.onSurfaceVariant\n\n    @Composable\n    fun navigationSelectedItemColor() = MaterialTheme.colorScheme.onPrimaryContainer\n\n    @Composable\n    fun navigationIndicatorColor() = MaterialTheme.colorScheme.primaryContainer\n}\n"
  },
  {
    "path": "core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Tabs.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem.component\n\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.ProvideTextStyle\nimport androidx.compose.material3.Tab\nimport androidx.compose.material3.TabRow\nimport androidx.compose.material3.TabRowDefaults\nimport androidx.compose.material3.TabRowDefaults.tabIndicatorOffset\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.text.style.TextAlign\nimport androidx.compose.ui.unit.dp\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\n\n/**\n * Now in Android tab. Wraps Material 3 [Tab] and shifts text label down.\n *\n * @param selected Whether this tab is selected or not.\n * @param onClick The callback to be invoked when this tab is selected.\n * @param modifier Modifier to be applied to the tab.\n * @param enabled Controls the enabled state of the tab. When `false`, this tab will not be\n * clickable and will appear disabled to accessibility services.\n * @param text The text label content.\n */\n@Composable\nfun NiaTab(\n    selected: Boolean,\n    onClick: () -> Unit,\n    modifier: Modifier = Modifier,\n    enabled: Boolean = true,\n    text: @Composable () -> Unit,\n) {\n    Tab(\n        selected = selected,\n        onClick = onClick,\n        modifier = modifier,\n        enabled = enabled,\n        text = {\n            val style = MaterialTheme.typography.labelLarge.copy(textAlign = TextAlign.Center)\n            ProvideTextStyle(\n                value = style,\n                content = {\n                    Box(modifier = Modifier.padding(top = NiaTabDefaults.TabTopPadding)) {\n                        text()\n                    }\n                },\n            )\n        },\n    )\n}\n\n/**\n * Now in Android tab row. Wraps Material 3 [TabRow].\n *\n * @param selectedTabIndex The index of the currently selected tab.\n * @param modifier Modifier to be applied to the tab row.\n * @param tabs The tabs inside this tab row. Typically this will be multiple [NiaTab]s. Each element\n * inside this lambda will be measured and placed evenly across the row, each taking up equal space.\n */\n@Composable\nfun NiaTabRow(\n    selectedTabIndex: Int,\n    modifier: Modifier = Modifier,\n    tabs: @Composable () -> Unit,\n) {\n    TabRow(\n        selectedTabIndex = selectedTabIndex,\n        modifier = modifier,\n        containerColor = Color.Transparent,\n        contentColor = MaterialTheme.colorScheme.onSurface,\n        indicator = { tabPositions ->\n            TabRowDefaults.SecondaryIndicator(\n                modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]),\n                height = 2.dp,\n                color = MaterialTheme.colorScheme.onSurface,\n            )\n        },\n        tabs = tabs,\n    )\n}\n\n@ThemePreviews\n@Composable\nfun TabsPreview() {\n    NiaTheme {\n        val titles = listOf(\"Topics\", \"People\")\n        NiaTabRow(selectedTabIndex = 0) {\n            titles.forEachIndexed { index, title ->\n                NiaTab(\n                    selected = index == 0,\n                    onClick = { },\n                    text = { Text(text = title) },\n                )\n            }\n        }\n    }\n}\n\nobject NiaTabDefaults {\n    val TabTopPadding = 7.dp\n}\n"
  },
  {
    "path": "core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Tag.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem.component\n\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.material3.ButtonDefaults\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.ProvideTextStyle\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TextButton\nimport androidx.compose.material3.contentColorFor\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\n\n@Composable\nfun NiaTopicTag(\n    modifier: Modifier = Modifier,\n    followed: Boolean,\n    onClick: () -> Unit,\n    enabled: Boolean = true,\n    text: @Composable () -> Unit,\n) {\n    Box(modifier = modifier) {\n        val containerColor = if (followed) {\n            MaterialTheme.colorScheme.primaryContainer\n        } else {\n            MaterialTheme.colorScheme.surfaceVariant.copy(\n                alpha = NiaTagDefaults.UNFOLLOWED_TOPIC_TAG_CONTAINER_ALPHA,\n            )\n        }\n        TextButton(\n            onClick = onClick,\n            enabled = enabled,\n            colors = ButtonDefaults.textButtonColors(\n                containerColor = containerColor,\n                contentColor = contentColorFor(backgroundColor = containerColor),\n                disabledContainerColor = MaterialTheme.colorScheme.onSurface.copy(\n                    alpha = NiaTagDefaults.DISABLED_TOPIC_TAG_CONTAINER_ALPHA,\n                ),\n            ),\n        ) {\n            ProvideTextStyle(value = MaterialTheme.typography.labelSmall) {\n                text()\n            }\n        }\n    }\n}\n\n@ThemePreviews\n@Composable\nfun TagPreview() {\n    NiaTheme {\n        NiaTopicTag(followed = true, onClick = {}) {\n            Text(\"Topic\".uppercase())\n        }\n    }\n}\n\n/**\n * Now in Android tag default values.\n */\nobject NiaTagDefaults {\n    const val UNFOLLOWED_TOPIC_TAG_CONTAINER_ALPHA = 0.5f\n\n    // TODO: File bug\n    // Button disabled container alpha value not exposed by ButtonDefaults\n    const val DISABLED_TOPIC_TAG_CONTAINER_ALPHA = 0.12f\n}\n"
  },
  {
    "path": "core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/TopAppBar.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n@file:OptIn(ExperimentalMaterial3Api::class)\n\npackage com.google.samples.apps.nowinandroid.core.designsystem.component\n\nimport androidx.annotation.StringRes\nimport androidx.compose.material3.CenterAlignedTopAppBar\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TopAppBarColors\nimport androidx.compose.material3.TopAppBarDefaults\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.compose.ui.platform.testTag\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.tooling.preview.Preview\nimport com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\n\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun NiaTopAppBar(\n    @StringRes titleRes: Int,\n    navigationIcon: ImageVector,\n    navigationIconContentDescription: String,\n    actionIcon: ImageVector,\n    actionIconContentDescription: String,\n    modifier: Modifier = Modifier,\n    colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(),\n    onNavigationClick: () -> Unit = {},\n    onActionClick: () -> Unit = {},\n) {\n    CenterAlignedTopAppBar(\n        title = { Text(text = stringResource(id = titleRes)) },\n        navigationIcon = {\n            IconButton(onClick = onNavigationClick) {\n                Icon(\n                    imageVector = navigationIcon,\n                    contentDescription = navigationIconContentDescription,\n                    tint = MaterialTheme.colorScheme.onSurface,\n                )\n            }\n        },\n        actions = {\n            IconButton(onClick = onActionClick) {\n                Icon(\n                    imageVector = actionIcon,\n                    contentDescription = actionIconContentDescription,\n                    tint = MaterialTheme.colorScheme.onSurface,\n                )\n            }\n        },\n        colors = colors,\n        modifier = modifier.testTag(\"niaTopAppBar\"),\n    )\n}\n\n@OptIn(ExperimentalMaterial3Api::class)\n@Preview(\"Top App Bar\")\n@Composable\nprivate fun NiaTopAppBarPreview() {\n    NiaTheme {\n        NiaTopAppBar(\n            titleRes = android.R.string.untitled,\n            navigationIcon = NiaIcons.Search,\n            navigationIconContentDescription = \"Navigation icon\",\n            actionIcon = NiaIcons.MoreVert,\n            actionIconContentDescription = \"Action icon\",\n        )\n    }\n}\n"
  },
  {
    "path": "core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/ViewToggle.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem.component\n\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.PaddingValues\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.sizeIn\nimport androidx.compose.material3.ButtonDefaults\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.ProvideTextStyle\nimport androidx.compose.material3.Surface\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TextButton\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.tooling.preview.Preview\nimport androidx.compose.ui.unit.dp\nimport com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\n\n/**\n * Now in Android view toggle button with included trailing icon as well as compact and expanded\n * text label content slots.\n *\n * @param expanded Whether the view toggle is currently in expanded mode or compact mode.\n * @param onExpandedChange Called when the user clicks the button and toggles the mode.\n * @param modifier Modifier to be applied to the button.\n * @param enabled Controls the enabled state of the button. When `false`, this button will not be\n * clickable and will appear disabled to accessibility services.\n * @param compactText The text label content to show in expanded mode.\n * @param expandedText The text label content to show in compact mode.\n */\n@Composable\nfun NiaViewToggleButton(\n    expanded: Boolean,\n    onExpandedChange: (Boolean) -> Unit,\n    modifier: Modifier = Modifier,\n    enabled: Boolean = true,\n    compactText: @Composable () -> Unit,\n    expandedText: @Composable () -> Unit,\n) {\n    TextButton(\n        onClick = { onExpandedChange(!expanded) },\n        modifier = modifier,\n        enabled = enabled,\n        colors = ButtonDefaults.textButtonColors(\n            contentColor = MaterialTheme.colorScheme.onBackground,\n        ),\n        contentPadding = NiaViewToggleDefaults.ViewToggleButtonContentPadding,\n    ) {\n        NiaViewToggleButtonContent(\n            text = if (expanded) expandedText else compactText,\n            trailingIcon = {\n                Icon(\n                    imageVector = if (expanded) NiaIcons.ViewDay else NiaIcons.ShortText,\n                    contentDescription = null,\n                )\n            },\n        )\n    }\n}\n\n/**\n * Internal Now in Android view toggle button content layout for arranging the text label and\n * trailing icon.\n *\n * @param text The button text label content.\n * @param trailingIcon The button trailing icon content. Default is `null` for no trailing icon.\n */\n@Composable\nprivate fun NiaViewToggleButtonContent(\n    text: @Composable () -> Unit,\n    trailingIcon: @Composable (() -> Unit)? = null,\n) {\n    Box(\n        Modifier\n            .padding(\n                end = if (trailingIcon != null) {\n                    ButtonDefaults.IconSpacing\n                } else {\n                    0.dp\n                },\n            ),\n    ) {\n        ProvideTextStyle(value = MaterialTheme.typography.labelSmall) {\n            text()\n        }\n    }\n    if (trailingIcon != null) {\n        Box(Modifier.sizeIn(maxHeight = ButtonDefaults.IconSize)) {\n            trailingIcon()\n        }\n    }\n}\n\n@ThemePreviews\n@Composable\nfun ViewTogglePreviewExpanded() {\n    NiaTheme {\n        Surface {\n            NiaViewToggleButton(\n                expanded = true,\n                onExpandedChange = { },\n                compactText = { Text(text = \"Compact view\") },\n                expandedText = { Text(text = \"Expanded view\") },\n            )\n        }\n    }\n}\n\n@Preview\n@Composable\nfun ViewTogglePreviewCompact() {\n    NiaTheme {\n        Surface {\n            NiaViewToggleButton(\n                expanded = false,\n                onExpandedChange = { },\n                compactText = { Text(text = \"Compact view\") },\n                expandedText = { Text(text = \"Expanded view\") },\n            )\n        }\n    }\n}\n\n/**\n * Now in Android view toggle default values.\n */\nobject NiaViewToggleDefaults {\n    // TODO: File bug\n    // Various default button padding values aren't exposed via ButtonDefaults\n    val ViewToggleButtonContentPadding =\n        PaddingValues(\n            start = 16.dp,\n            top = 8.dp,\n            end = 12.dp,\n            bottom = 8.dp,\n        )\n}\n"
  },
  {
    "path": "core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/AppScrollbars.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar\n\nimport android.annotation.SuppressLint\nimport androidx.compose.animation.animateColorAsState\nimport androidx.compose.animation.core.Spring\nimport androidx.compose.animation.core.SpringSpec\nimport androidx.compose.foundation.gestures.Orientation\nimport androidx.compose.foundation.gestures.Orientation.Horizontal\nimport androidx.compose.foundation.gestures.Orientation.Vertical\nimport androidx.compose.foundation.gestures.ScrollableState\nimport androidx.compose.foundation.interaction.InteractionSource\nimport androidx.compose.foundation.interaction.MutableInteractionSource\nimport androidx.compose.foundation.interaction.collectIsDraggedAsState\nimport androidx.compose.foundation.interaction.collectIsHoveredAsState\nimport androidx.compose.foundation.interaction.collectIsPressedAsState\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.fillMaxHeight\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.State\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.geometry.Size\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.graphics.ColorProducer\nimport androidx.compose.ui.graphics.Outline\nimport androidx.compose.ui.graphics.drawOutline\nimport androidx.compose.ui.graphics.drawscope.ContentDrawScope\nimport androidx.compose.ui.node.DrawModifierNode\nimport androidx.compose.ui.node.ModifierNodeElement\nimport androidx.compose.ui.node.invalidateDraw\nimport androidx.compose.ui.unit.LayoutDirection\nimport androidx.compose.ui.unit.dp\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.ThumbState.Active\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.ThumbState.Dormant\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.ThumbState.Inactive\nimport kotlinx.coroutines.delay\n\n/**\n * The time period for showing the scrollbar thumb after interacting with it, before it fades away\n */\nprivate const val SCROLLBAR_INACTIVE_TO_DORMANT_TIME_IN_MS = 2_000L\n\n/**\n * A [Scrollbar] that allows for fast scrolling of content by dragging its thumb.\n * Its thumb disappears when the scrolling container is dormant.\n * @param modifier a [Modifier] for the [Scrollbar]\n * @param state the driving state for the [Scrollbar]\n * @param orientation the orientation of the scrollbar\n * @param onThumbMoved the fast scroll implementation\n */\n@Composable\nfun ScrollableState.DraggableScrollbar(\n    state: ScrollbarState,\n    orientation: Orientation,\n    onThumbMoved: (Float) -> Unit,\n    modifier: Modifier = Modifier,\n) {\n    val interactionSource = remember { MutableInteractionSource() }\n    Scrollbar(\n        modifier = modifier,\n        orientation = orientation,\n        interactionSource = interactionSource,\n        state = state,\n        thumb = {\n            DraggableScrollbarThumb(\n                interactionSource = interactionSource,\n                orientation = orientation,\n            )\n        },\n        onThumbMoved = onThumbMoved,\n    )\n}\n\n/**\n * A simple [Scrollbar].\n * Its thumb disappears when the scrolling container is dormant.\n * @param modifier a [Modifier] for the [Scrollbar]\n * @param state the driving state for the [Scrollbar]\n * @param orientation the orientation of the scrollbar\n */\n@Composable\nfun ScrollableState.DecorativeScrollbar(\n    state: ScrollbarState,\n    orientation: Orientation,\n    modifier: Modifier = Modifier,\n) {\n    val interactionSource = remember { MutableInteractionSource() }\n    Scrollbar(\n        modifier = modifier,\n        orientation = orientation,\n        interactionSource = interactionSource,\n        state = state,\n        thumb = {\n            DecorativeScrollbarThumb(\n                interactionSource = interactionSource,\n                orientation = orientation,\n            )\n        },\n    )\n}\n\n/**\n * A scrollbar thumb that is intended to also be a touch target for fast scrolling.\n */\n@Composable\nprivate fun ScrollableState.DraggableScrollbarThumb(\n    interactionSource: InteractionSource,\n    orientation: Orientation,\n) {\n    Box(\n        modifier = Modifier\n            .run {\n                when (orientation) {\n                    Vertical -> width(12.dp).fillMaxHeight()\n                    Horizontal -> height(12.dp).fillMaxWidth()\n                }\n            }\n            .scrollThumb(this, interactionSource),\n    )\n}\n\n/**\n * A decorative scrollbar thumb used solely for communicating a user's position in a list.\n */\n@Composable\nprivate fun ScrollableState.DecorativeScrollbarThumb(\n    interactionSource: InteractionSource,\n    orientation: Orientation,\n) {\n    Box(\n        modifier = Modifier\n            .run {\n                when (orientation) {\n                    Vertical -> width(2.dp).fillMaxHeight()\n                    Horizontal -> height(2.dp).fillMaxWidth()\n                }\n            }\n            .scrollThumb(this, interactionSource),\n    )\n}\n\n// TODO: This lint is removed in 1.6 as the recommendation has changed\n// remove when project is upgraded\n@SuppressLint(\"ComposableModifierFactory\")\n@Composable\nprivate fun Modifier.scrollThumb(\n    scrollableState: ScrollableState,\n    interactionSource: InteractionSource,\n): Modifier {\n    val colorState = scrollbarThumbColor(scrollableState, interactionSource)\n    return this then ScrollThumbElement { colorState.value }\n}\n\nprivate data class ScrollThumbElement(val colorProducer: ColorProducer) :\n    ModifierNodeElement<ScrollThumbNode>() {\n    override fun create(): ScrollThumbNode = ScrollThumbNode(colorProducer)\n    override fun update(node: ScrollThumbNode) {\n        node.colorProducer = colorProducer\n        node.invalidateDraw()\n    }\n}\n\nprivate class ScrollThumbNode(var colorProducer: ColorProducer) : DrawModifierNode, Modifier.Node() {\n    private val shape = RoundedCornerShape(16.dp)\n\n    // naive cache outline calculation if size is the same\n    private var lastSize: Size? = null\n    private var lastLayoutDirection: LayoutDirection? = null\n    private var lastOutline: Outline? = null\n\n    override fun ContentDrawScope.draw() {\n        val color = colorProducer()\n        val outline =\n            if (size == lastSize && layoutDirection == lastLayoutDirection) {\n                lastOutline!!\n            } else {\n                shape.createOutline(size, layoutDirection, this)\n            }\n        if (color != Color.Unspecified) drawOutline(outline, color = color)\n\n        lastOutline = outline\n        lastSize = size\n        lastLayoutDirection = layoutDirection\n    }\n}\n\n/**\n * The color of the scrollbar thumb as a function of its interaction state.\n * @param interactionSource source of interactions in the scrolling container\n */\n@Composable\nprivate fun scrollbarThumbColor(\n    scrollableState: ScrollableState,\n    interactionSource: InteractionSource,\n): State<Color> {\n    var state by remember { mutableStateOf(Dormant) }\n    val pressed by interactionSource.collectIsPressedAsState()\n    val hovered by interactionSource.collectIsHoveredAsState()\n    val dragged by interactionSource.collectIsDraggedAsState()\n    val active = (scrollableState.canScrollForward || scrollableState.canScrollBackward) &&\n        (pressed || hovered || dragged || scrollableState.isScrollInProgress)\n\n    val color = animateColorAsState(\n        targetValue = when (state) {\n            Active -> MaterialTheme.colorScheme.onSurface.copy(0.5f)\n            Inactive -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f)\n            Dormant -> Color.Transparent\n        },\n        animationSpec = SpringSpec(\n            stiffness = Spring.StiffnessLow,\n        ),\n        label = \"Scrollbar thumb color\",\n    )\n    LaunchedEffect(active) {\n        when (active) {\n            true -> state = Active\n            false -> if (state == Active) {\n                state = Inactive\n                delay(SCROLLBAR_INACTIVE_TO_DORMANT_TIME_IN_MS)\n                state = Dormant\n            }\n        }\n    }\n\n    return color\n}\n\nprivate enum class ThumbState {\n    Active,\n    Inactive,\n    Dormant,\n}\n"
  },
  {
    "path": "core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/LazyScrollbarUtilities.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar\n\nimport androidx.compose.foundation.gestures.ScrollableState\nimport kotlin.math.abs\n\n/**\n * Linearly interpolates the index for the first item in [visibleItems] for smooth scrollbar\n * progression.\n * @param visibleItems a list of items currently visible in the layout.\n * @param itemSize a lookup function for the size of an item in the layout.\n * @param offset a lookup function for the offset of an item relative to the start of the view port.\n * @param nextItemOnMainAxis a lookup function for the next item on the main axis in the direction\n * of the scroll.\n * @param itemIndex a lookup function for index of an item in the layout relative to\n * the total amount of items available.\n *\n * @return a [Float] in the range [firstItemPosition..nextItemPosition) where nextItemPosition\n * is the index of the consecutive item along the major axis.\n * */\ninternal inline fun <LazyState : ScrollableState, LazyStateItem> LazyState.interpolateFirstItemIndex(\n    visibleItems: List<LazyStateItem>,\n    crossinline itemSize: LazyState.(LazyStateItem) -> Int,\n    crossinline offset: LazyState.(LazyStateItem) -> Int,\n    crossinline nextItemOnMainAxis: LazyState.(LazyStateItem) -> LazyStateItem?,\n    crossinline itemIndex: (LazyStateItem) -> Int,\n): Float {\n    if (visibleItems.isEmpty()) return 0f\n\n    val firstItem = visibleItems.first()\n    val firstItemIndex = itemIndex(firstItem)\n\n    if (firstItemIndex < 0) return Float.NaN\n\n    val firstItemSize = itemSize(firstItem)\n    if (firstItemSize == 0) return Float.NaN\n\n    val itemOffset = offset(firstItem).toFloat()\n    val offsetPercentage = abs(itemOffset) / firstItemSize\n\n    val nextItem = nextItemOnMainAxis(firstItem) ?: return firstItemIndex + offsetPercentage\n\n    val nextItemIndex = itemIndex(nextItem)\n\n    return firstItemIndex + ((nextItemIndex - firstItemIndex) * offsetPercentage)\n}\n\n/**\n * Returns the percentage of an item that is currently visible in the view port.\n * @param itemSize the size of the item\n * @param itemStartOffset the start offset of the item relative to the view port start\n * @param viewportStartOffset the start offset of the view port\n * @param viewportEndOffset the end offset of the view port\n */\ninternal fun itemVisibilityPercentage(\n    itemSize: Int,\n    itemStartOffset: Int,\n    viewportStartOffset: Int,\n    viewportEndOffset: Int,\n): Float {\n    if (itemSize == 0) return 0f\n    val itemEnd = itemStartOffset + itemSize\n    val startOffset = when {\n        itemStartOffset > viewportStartOffset -> 0\n        else -> abs(abs(viewportStartOffset) - abs(itemStartOffset))\n    }\n    val endOffset = when {\n        itemEnd < viewportEndOffset -> 0\n        else -> abs(abs(itemEnd) - abs(viewportEndOffset))\n    }\n    val size = itemSize.toFloat()\n    return (size - startOffset - endOffset) / size\n}\n"
  },
  {
    "path": "core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/Scrollbar.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar\n\nimport androidx.compose.foundation.gestures.Orientation\nimport androidx.compose.foundation.gestures.Orientation.Horizontal\nimport androidx.compose.foundation.gestures.Orientation.Vertical\nimport androidx.compose.foundation.gestures.detectHorizontalDragGestures\nimport androidx.compose.foundation.gestures.detectTapGestures\nimport androidx.compose.foundation.gestures.detectVerticalDragGestures\nimport androidx.compose.foundation.hoverable\nimport androidx.compose.foundation.interaction.DragInteraction\nimport androidx.compose.foundation.interaction.MutableInteractionSource\nimport androidx.compose.foundation.interaction.PressInteraction\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.fillMaxHeight\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.Immutable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableFloatStateOf\nimport androidx.compose.runtime.mutableLongStateOf\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.runtime.snapshotFlow\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.geometry.Offset\nimport androidx.compose.ui.input.pointer.PointerInputChange\nimport androidx.compose.ui.input.pointer.pointerInput\nimport androidx.compose.ui.layout.Layout\nimport androidx.compose.ui.layout.layout\nimport androidx.compose.ui.layout.onGloballyPositioned\nimport androidx.compose.ui.layout.positionInRoot\nimport androidx.compose.ui.unit.Dp\nimport androidx.compose.ui.unit.IntOffset\nimport androidx.compose.ui.unit.IntSize\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.util.packFloats\nimport androidx.compose.ui.util.unpackFloat1\nimport androidx.compose.ui.util.unpackFloat2\nimport kotlinx.coroutines.TimeoutCancellationException\nimport kotlinx.coroutines.delay\nimport kotlinx.coroutines.withTimeout\nimport kotlin.math.max\nimport kotlin.math.min\nimport kotlin.math.roundToInt\n\n/**\n * The delay between scrolls when a user long presses on the scrollbar track to initiate a scroll\n * instead of dragging the scrollbar thumb.\n */\nprivate const val SCROLLBAR_PRESS_DELAY_MS = 10L\n\n/**\n * The percentage displacement of the scrollbar when scrolled by long presses on the scrollbar\n * track.\n */\nprivate const val SCROLLBAR_PRESS_DELTA_PCT = 0.02f\n\nclass ScrollbarState {\n    private var packedValue by mutableLongStateOf(0L)\n\n    internal fun onScroll(stateValue: ScrollbarStateValue) {\n        packedValue = stateValue.packedValue\n    }\n\n    /**\n     * Returns the thumb size of the scrollbar as a percentage of the total track size\n     */\n    val thumbSizePercent\n        get() = unpackFloat1(packedValue)\n\n    /**\n     * Returns the distance the thumb has traveled as a percentage of total track size\n     */\n    val thumbMovedPercent\n        get() = unpackFloat2(packedValue)\n\n    /**\n     * Returns the max distance the thumb can travel as a percentage of total track size\n     */\n    val thumbTrackSizePercent\n        get() = 1f - thumbSizePercent\n}\n\n/**\n * Returns the size of the scrollbar track in pixels\n */\nprivate val ScrollbarTrack.size\n    get() = unpackFloat2(packedValue) - unpackFloat1(packedValue)\n\n/**\n * Returns the position of the scrollbar thumb on the track as a percentage\n */\nprivate fun ScrollbarTrack.thumbPosition(\n    dimension: Float,\n): Float = max(\n    a = min(\n        a = dimension / size,\n        b = 1f,\n    ),\n    b = 0f,\n)\n\n/**\n * Class definition for the core properties of a scroll bar\n */\n@Immutable\n@JvmInline\nvalue class ScrollbarStateValue internal constructor(\n    internal val packedValue: Long,\n)\n\n/**\n * Class definition for the core properties of a scroll bar track\n */\n@Immutable\n@JvmInline\nprivate value class ScrollbarTrack(\n    val packedValue: Long,\n) {\n    constructor(\n        max: Float,\n        min: Float,\n    ) : this(packFloats(max, min))\n}\n\n/**\n * Creates a [ScrollbarStateValue] with the listed properties\n * @param thumbSizePercent the thumb size of the scrollbar as a percentage of the total track size.\n *  Refers to either the thumb width (for horizontal scrollbars)\n *  or height (for vertical scrollbars).\n * @param thumbMovedPercent the distance the thumb has traveled as a percentage of total\n * track size.\n */\nfun scrollbarStateValue(\n    thumbSizePercent: Float,\n    thumbMovedPercent: Float,\n) = ScrollbarStateValue(\n    packFloats(\n        val1 = thumbSizePercent,\n        val2 = thumbMovedPercent,\n    ),\n)\n\n/**\n * Returns the value of [offset] along the axis specified by [this]\n */\ninternal fun Orientation.valueOf(offset: Offset) = when (this) {\n    Orientation.Horizontal -> offset.x\n    Orientation.Vertical -> offset.y\n}\n\n/**\n * Returns the value of [intSize] along the axis specified by [this]\n */\ninternal fun Orientation.valueOf(intSize: IntSize) = when (this) {\n    Orientation.Horizontal -> intSize.width\n    Orientation.Vertical -> intSize.height\n}\n\n/**\n * Returns the value of [intOffset] along the axis specified by [this]\n */\ninternal fun Orientation.valueOf(intOffset: IntOffset) = when (this) {\n    Orientation.Horizontal -> intOffset.x\n    Orientation.Vertical -> intOffset.y\n}\n\n/**\n * A Composable for drawing a scrollbar\n * @param orientation the scroll direction of the scrollbar\n * @param state the state describing the position of the scrollbar\n * @param minThumbSize the minimum size of the scrollbar thumb\n * @param interactionSource allows for observing the state of the scroll bar\n * @param thumb a composable for drawing the scrollbar thumb\n * @param onThumbMoved an function for reacting to scroll bar displacements caused by direct\n * interactions on the scrollbar thumb by the user, for example implementing a fast scroll\n */\n@Composable\nfun Scrollbar(\n    orientation: Orientation,\n    state: ScrollbarState,\n    modifier: Modifier = Modifier,\n    interactionSource: MutableInteractionSource? = null,\n    minThumbSize: Dp = 40.dp,\n    onThumbMoved: ((Float) -> Unit)? = null,\n    thumb: @Composable () -> Unit,\n) {\n    // Using Offset.Unspecified and Float.NaN instead of null\n    // to prevent unnecessary boxing of primitives\n    var pressedOffset by remember { mutableStateOf(Offset.Unspecified) }\n    var draggedOffset by remember { mutableStateOf(Offset.Unspecified) }\n\n    // Used to immediately show drag feedback in the UI while the scrolling implementation\n    // catches up\n    var interactionThumbTravelPercent by remember { mutableFloatStateOf(Float.NaN) }\n\n    var track by remember { mutableStateOf(ScrollbarTrack(packedValue = 0)) }\n\n    // scrollbar track container\n    Box(\n        modifier = modifier\n            .run {\n                val withHover = interactionSource?.let(::hoverable) ?: this\n                when (orientation) {\n                    Orientation.Vertical -> withHover.fillMaxHeight()\n                    Orientation.Horizontal -> withHover.fillMaxWidth()\n                }\n            }\n            .onGloballyPositioned { coordinates ->\n                val scrollbarStartCoordinate = orientation.valueOf(coordinates.positionInRoot())\n                track = ScrollbarTrack(\n                    max = scrollbarStartCoordinate,\n                    min = scrollbarStartCoordinate + orientation.valueOf(coordinates.size),\n                )\n            }\n            // Process scrollbar presses\n            .pointerInput(Unit) {\n                detectTapGestures(\n                    onPress = { offset ->\n                        try {\n                            // Wait for a long press before scrolling\n                            withTimeout(viewConfiguration.longPressTimeoutMillis) {\n                                tryAwaitRelease()\n                            }\n                        } catch (e: TimeoutCancellationException) {\n                            // Start the press triggered scroll\n                            val initialPress = PressInteraction.Press(offset)\n                            interactionSource?.tryEmit(initialPress)\n\n                            pressedOffset = offset\n                            interactionSource?.tryEmit(\n                                when {\n                                    tryAwaitRelease() -> PressInteraction.Release(initialPress)\n                                    else -> PressInteraction.Cancel(initialPress)\n                                },\n                            )\n\n                            // End the press\n                            pressedOffset = Offset.Unspecified\n                        }\n                    },\n                )\n            }\n            // Process scrollbar drags\n            .pointerInput(Unit) {\n                var dragInteraction: DragInteraction.Start? = null\n                val onDragStart: (Offset) -> Unit = { offset ->\n                    val start = DragInteraction.Start()\n                    dragInteraction = start\n                    interactionSource?.tryEmit(start)\n                    draggedOffset = offset\n                }\n                val onDragEnd: () -> Unit = {\n                    dragInteraction?.let { interactionSource?.tryEmit(DragInteraction.Stop(it)) }\n                    draggedOffset = Offset.Unspecified\n                }\n                val onDragCancel: () -> Unit = {\n                    dragInteraction?.let { interactionSource?.tryEmit(DragInteraction.Cancel(it)) }\n                    draggedOffset = Offset.Unspecified\n                }\n                val onDrag: (change: PointerInputChange, dragAmount: Float) -> Unit =\n                    onDrag@{ _, delta ->\n                        if (draggedOffset == Offset.Unspecified) return@onDrag\n                        draggedOffset = when (orientation) {\n                            Orientation.Vertical -> draggedOffset.copy(\n                                y = draggedOffset.y + delta,\n                            )\n\n                            Orientation.Horizontal -> draggedOffset.copy(\n                                x = draggedOffset.x + delta,\n                            )\n                        }\n                    }\n\n                when (orientation) {\n                    Orientation.Horizontal -> detectHorizontalDragGestures(\n                        onDragStart = onDragStart,\n                        onDragEnd = onDragEnd,\n                        onDragCancel = onDragCancel,\n                        onHorizontalDrag = onDrag,\n                    )\n\n                    Orientation.Vertical -> detectVerticalDragGestures(\n                        onDragStart = onDragStart,\n                        onDragEnd = onDragEnd,\n                        onDragCancel = onDragCancel,\n                        onVerticalDrag = onDrag,\n                    )\n                }\n            },\n    ) {\n        // scrollbar thumb container\n        Layout(content = { thumb() }) { measurables, constraints ->\n            val measurable = measurables.first()\n\n            val thumbSizePx = max(\n                a = state.thumbSizePercent * track.size,\n                b = minThumbSize.toPx(),\n            )\n\n            val trackSizePx = when (state.thumbTrackSizePercent) {\n                0f -> track.size\n                else -> (track.size - thumbSizePx) / state.thumbTrackSizePercent\n            }\n\n            val thumbTravelPercent = max(\n                a = min(\n                    a = when {\n                        interactionThumbTravelPercent.isNaN() -> state.thumbMovedPercent\n                        else -> interactionThumbTravelPercent\n                    },\n                    b = state.thumbTrackSizePercent,\n                ),\n                b = 0f,\n            )\n\n            val thumbMovedPx = trackSizePx * thumbTravelPercent\n\n            val y = when (orientation) {\n                Horizontal -> 0\n                Vertical -> thumbMovedPx.roundToInt()\n            }\n            val x = when (orientation) {\n                Horizontal -> thumbMovedPx.roundToInt()\n                Vertical -> 0\n            }\n\n            val updatedConstraints = when (orientation) {\n                Horizontal -> {\n                    constraints.copy(\n                        minWidth = thumbSizePx.roundToInt(),\n                        maxWidth = thumbSizePx.roundToInt(),\n                    )\n                }\n                Vertical -> {\n                    constraints.copy(\n                        minHeight = thumbSizePx.roundToInt(),\n                        maxHeight = thumbSizePx.roundToInt(),\n                    )\n                }\n            }\n\n            val placeable = measurable.measure(updatedConstraints)\n            layout(placeable.width, placeable.height) {\n                placeable.place(x, y)\n            }\n        }\n    }\n\n    if (onThumbMoved == null) return\n\n    // Process presses\n    LaunchedEffect(Unit) {\n        snapshotFlow { pressedOffset }.collect { pressedOffset ->\n            // Press ended, reset interactionThumbTravelPercent\n            if (pressedOffset == Offset.Unspecified) {\n                interactionThumbTravelPercent = Float.NaN\n                return@collect\n            }\n\n            var currentThumbMovedPercent = state.thumbMovedPercent\n            val destinationThumbMovedPercent = track.thumbPosition(\n                dimension = orientation.valueOf(pressedOffset),\n            )\n            val isPositive = currentThumbMovedPercent < destinationThumbMovedPercent\n            val delta = SCROLLBAR_PRESS_DELTA_PCT * if (isPositive) 1f else -1f\n\n            while (currentThumbMovedPercent != destinationThumbMovedPercent) {\n                currentThumbMovedPercent = when {\n                    isPositive -> min(\n                        a = currentThumbMovedPercent + delta,\n                        b = destinationThumbMovedPercent,\n                    )\n\n                    else -> max(\n                        a = currentThumbMovedPercent + delta,\n                        b = destinationThumbMovedPercent,\n                    )\n                }\n                onThumbMoved(currentThumbMovedPercent)\n                interactionThumbTravelPercent = currentThumbMovedPercent\n                delay(SCROLLBAR_PRESS_DELAY_MS)\n            }\n        }\n    }\n\n    // Process drags\n    LaunchedEffect(Unit) {\n        snapshotFlow { draggedOffset }.collect { draggedOffset ->\n            if (draggedOffset == Offset.Unspecified) {\n                interactionThumbTravelPercent = Float.NaN\n                return@collect\n            }\n            val currentTravel = track.thumbPosition(\n                dimension = orientation.valueOf(draggedOffset),\n            )\n            onThumbMoved(currentTravel)\n            interactionThumbTravelPercent = currentTravel\n        }\n    }\n}\n"
  },
  {
    "path": "core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ScrollbarExt.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar\n\nimport androidx.compose.foundation.gestures.Orientation\nimport androidx.compose.foundation.lazy.LazyListItemInfo\nimport androidx.compose.foundation.lazy.LazyListState\nimport androidx.compose.foundation.lazy.grid.LazyGridItemInfo\nimport androidx.compose.foundation.lazy.grid.LazyGridState\nimport androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemInfo\nimport androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.snapshotFlow\nimport kotlinx.coroutines.flow.distinctUntilChanged\nimport kotlinx.coroutines.flow.filterNotNull\nimport kotlin.math.min\n\n/**\n * Calculates a [ScrollbarState] driven by the changes in a [LazyListState].\n *\n * @param itemsAvailable the total amount of items available to scroll in the lazy list.\n * @param itemIndex a lookup function for index of an item in the list relative to [itemsAvailable].\n */\n@Composable\nfun LazyListState.scrollbarState(\n    itemsAvailable: Int,\n    itemIndex: (LazyListItemInfo) -> Int = LazyListItemInfo::index,\n): ScrollbarState {\n    val state = remember { ScrollbarState() }\n    LaunchedEffect(this, itemsAvailable) {\n        snapshotFlow {\n            if (itemsAvailable == 0) return@snapshotFlow null\n\n            val visibleItemsInfo = layoutInfo.visibleItemsInfo\n            if (visibleItemsInfo.isEmpty()) return@snapshotFlow null\n\n            val firstIndex = min(\n                a = interpolateFirstItemIndex(\n                    visibleItems = visibleItemsInfo,\n                    itemSize = { it.size },\n                    offset = { it.offset },\n                    nextItemOnMainAxis = { first -> visibleItemsInfo.find { it != first } },\n                    itemIndex = itemIndex,\n                ),\n                b = itemsAvailable.toFloat(),\n            )\n            if (firstIndex.isNaN()) return@snapshotFlow null\n\n            val itemsVisible = visibleItemsInfo.floatSumOf { itemInfo ->\n                itemVisibilityPercentage(\n                    itemSize = itemInfo.size,\n                    itemStartOffset = itemInfo.offset,\n                    viewportStartOffset = layoutInfo.viewportStartOffset,\n                    viewportEndOffset = layoutInfo.viewportEndOffset,\n                )\n            }\n\n            val thumbTravelPercent = min(\n                a = firstIndex / itemsAvailable,\n                b = 1f,\n            )\n            val thumbSizePercent = min(\n                a = itemsVisible / itemsAvailable,\n                b = 1f,\n            )\n            scrollbarStateValue(\n                thumbSizePercent = thumbSizePercent,\n                thumbMovedPercent = when {\n                    layoutInfo.reverseLayout -> 1f - thumbTravelPercent\n                    else -> thumbTravelPercent\n                },\n            )\n        }\n            .filterNotNull()\n            .distinctUntilChanged()\n            .collect { state.onScroll(it) }\n    }\n    return state\n}\n\n/**\n * Calculates a [ScrollbarState] driven by the changes in a [LazyGridState]\n *\n * @param itemsAvailable the total amount of items available to scroll in the grid.\n * @param itemIndex a lookup function for index of an item in the grid relative to [itemsAvailable].\n */\n@Composable\nfun LazyGridState.scrollbarState(\n    itemsAvailable: Int,\n    itemIndex: (LazyGridItemInfo) -> Int = LazyGridItemInfo::index,\n): ScrollbarState {\n    val state = remember { ScrollbarState() }\n    LaunchedEffect(this, itemsAvailable) {\n        snapshotFlow {\n            if (itemsAvailable == 0) return@snapshotFlow null\n\n            val visibleItemsInfo = layoutInfo.visibleItemsInfo\n            if (visibleItemsInfo.isEmpty()) return@snapshotFlow null\n\n            val firstIndex = min(\n                a = interpolateFirstItemIndex(\n                    visibleItems = visibleItemsInfo,\n                    itemSize = { layoutInfo.orientation.valueOf(it.size) },\n                    offset = { layoutInfo.orientation.valueOf(it.offset) },\n                    nextItemOnMainAxis = { first ->\n                        when (layoutInfo.orientation) {\n                            Orientation.Vertical -> visibleItemsInfo.find {\n                                it != first && it.row != first.row\n                            }\n\n                            Orientation.Horizontal -> visibleItemsInfo.find {\n                                it != first && it.column != first.column\n                            }\n                        }\n                    },\n                    itemIndex = itemIndex,\n                ),\n                b = itemsAvailable.toFloat(),\n            )\n            if (firstIndex.isNaN()) return@snapshotFlow null\n\n            val itemsVisible = visibleItemsInfo.floatSumOf { itemInfo ->\n                itemVisibilityPercentage(\n                    itemSize = layoutInfo.orientation.valueOf(itemInfo.size),\n                    itemStartOffset = layoutInfo.orientation.valueOf(itemInfo.offset),\n                    viewportStartOffset = layoutInfo.viewportStartOffset,\n                    viewportEndOffset = layoutInfo.viewportEndOffset,\n                )\n            }\n\n            val thumbTravelPercent = min(\n                a = firstIndex / itemsAvailable,\n                b = 1f,\n            )\n            val thumbSizePercent = min(\n                a = itemsVisible / itemsAvailable,\n                b = 1f,\n            )\n            scrollbarStateValue(\n                thumbSizePercent = thumbSizePercent,\n                thumbMovedPercent = when {\n                    layoutInfo.reverseLayout -> 1f - thumbTravelPercent\n                    else -> thumbTravelPercent\n                },\n            )\n        }\n            .filterNotNull()\n            .distinctUntilChanged()\n            .collect { state.onScroll(it) }\n    }\n    return state\n}\n\n/**\n * Remembers a [ScrollbarState] driven by the changes in a [LazyStaggeredGridState]\n *\n * @param itemsAvailable the total amount of items available to scroll in the staggered grid.\n * @param itemIndex a lookup function for index of an item in the staggered grid relative\n * to [itemsAvailable].\n */\n@Composable\nfun LazyStaggeredGridState.scrollbarState(\n    itemsAvailable: Int,\n    itemIndex: (LazyStaggeredGridItemInfo) -> Int = LazyStaggeredGridItemInfo::index,\n): ScrollbarState {\n    val state = remember { ScrollbarState() }\n    LaunchedEffect(this, itemsAvailable) {\n        snapshotFlow {\n            if (itemsAvailable == 0) return@snapshotFlow null\n\n            val visibleItemsInfo = layoutInfo.visibleItemsInfo\n            if (visibleItemsInfo.isEmpty()) return@snapshotFlow null\n\n            val firstIndex = min(\n                a = interpolateFirstItemIndex(\n                    visibleItems = visibleItemsInfo,\n                    itemSize = { layoutInfo.orientation.valueOf(it.size) },\n                    offset = { layoutInfo.orientation.valueOf(it.offset) },\n                    nextItemOnMainAxis = { first ->\n                        visibleItemsInfo.find { it != first && it.lane == first.lane }\n                    },\n                    itemIndex = itemIndex,\n                ),\n                b = itemsAvailable.toFloat(),\n            )\n            if (firstIndex.isNaN()) return@snapshotFlow null\n\n            val itemsVisible = visibleItemsInfo.floatSumOf { itemInfo ->\n                itemVisibilityPercentage(\n                    itemSize = layoutInfo.orientation.valueOf(itemInfo.size),\n                    itemStartOffset = layoutInfo.orientation.valueOf(itemInfo.offset),\n                    viewportStartOffset = layoutInfo.viewportStartOffset,\n                    viewportEndOffset = layoutInfo.viewportEndOffset,\n                )\n            }\n\n            val thumbTravelPercent = min(\n                a = firstIndex / itemsAvailable,\n                b = 1f,\n            )\n            val thumbSizePercent = min(\n                a = itemsVisible / itemsAvailable,\n                b = 1f,\n            )\n            scrollbarStateValue(\n                thumbSizePercent = thumbSizePercent,\n                thumbMovedPercent = thumbTravelPercent,\n            )\n        }\n            .filterNotNull()\n            .distinctUntilChanged()\n            .collect { state.onScroll(it) }\n    }\n    return state\n}\n\nprivate inline fun <T> List<T>.floatSumOf(selector: (T) -> Float): Float =\n    fold(initial = 0f) { accumulator, listItem -> accumulator + selector(listItem) }\n"
  },
  {
    "path": "core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ThumbExt.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar\n\nimport androidx.compose.foundation.lazy.LazyListState\nimport androidx.compose.foundation.lazy.grid.LazyGridState\nimport androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableFloatStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberUpdatedState\nimport androidx.compose.runtime.setValue\nimport kotlin.math.roundToInt\n\n/**\n * Remembers a function to react to [Scrollbar] thumb position displacements for a [LazyListState]\n * @param itemsAvailable the amount of items in the list.\n */\n@Composable\nfun LazyListState.rememberDraggableScroller(\n    itemsAvailable: Int,\n): (Float) -> Unit = rememberDraggableScroller(\n    itemsAvailable = itemsAvailable,\n    scroll = ::scrollToItem,\n)\n\n/**\n * Remembers a function to react to [Scrollbar] thumb position displacements for a [LazyGridState]\n * @param itemsAvailable the amount of items in the grid.\n */\n@Composable\nfun LazyGridState.rememberDraggableScroller(\n    itemsAvailable: Int,\n): (Float) -> Unit = rememberDraggableScroller(\n    itemsAvailable = itemsAvailable,\n    scroll = ::scrollToItem,\n)\n\n/**\n * Remembers a function to react to [Scrollbar] thumb position displacements for a\n * [LazyStaggeredGridState]\n * @param itemsAvailable the amount of items in the staggered grid.\n */\n@Composable\nfun LazyStaggeredGridState.rememberDraggableScroller(\n    itemsAvailable: Int,\n): (Float) -> Unit = rememberDraggableScroller(\n    itemsAvailable = itemsAvailable,\n    scroll = ::scrollToItem,\n)\n\n/**\n * Generic function to react to [Scrollbar] thumb displacements in a lazy layout.\n * @param itemsAvailable the total amount of items available to scroll in the layout.\n * @param scroll a function to be invoked when an index has been identified to scroll to.\n */\n@Composable\nprivate inline fun rememberDraggableScroller(\n    itemsAvailable: Int,\n    crossinline scroll: suspend (index: Int) -> Unit,\n): (Float) -> Unit {\n    var percentage by remember { mutableFloatStateOf(Float.NaN) }\n    val itemCount by rememberUpdatedState(itemsAvailable)\n\n    LaunchedEffect(percentage) {\n        if (percentage.isNaN()) return@LaunchedEffect\n        val indexToFind = (itemCount * percentage).roundToInt()\n        scroll(indexToFind)\n    }\n    return remember {\n        { newPercentage -> percentage = newPercentage }\n    }\n}\n"
  },
  {
    "path": "core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/icon/NiaIcons.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem.icon\n\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.rounded.ArrowBack\nimport androidx.compose.material.icons.automirrored.rounded.ShortText\nimport androidx.compose.material.icons.filled.MoreVert\nimport androidx.compose.material.icons.outlined.Bookmarks\nimport androidx.compose.material.icons.outlined.Upcoming\nimport androidx.compose.material.icons.rounded.Add\nimport androidx.compose.material.icons.rounded.Bookmark\nimport androidx.compose.material.icons.rounded.BookmarkBorder\nimport androidx.compose.material.icons.rounded.Bookmarks\nimport androidx.compose.material.icons.rounded.Check\nimport androidx.compose.material.icons.rounded.Close\nimport androidx.compose.material.icons.rounded.Grid3x3\nimport androidx.compose.material.icons.rounded.Person\nimport androidx.compose.material.icons.rounded.Search\nimport androidx.compose.material.icons.rounded.Settings\nimport androidx.compose.material.icons.rounded.Upcoming\nimport androidx.compose.material.icons.rounded.ViewDay\nimport androidx.compose.ui.graphics.vector.ImageVector\n\n/**\n * Now in Android icons. Material icons are [ImageVector]s, custom icons are drawable resource IDs.\n */\nobject NiaIcons {\n    val Add = Icons.Rounded.Add\n    val ArrowBack = Icons.AutoMirrored.Rounded.ArrowBack\n    val Bookmark = Icons.Rounded.Bookmark\n    val BookmarkBorder = Icons.Rounded.BookmarkBorder\n    val Bookmarks = Icons.Rounded.Bookmarks\n    val BookmarksBorder = Icons.Outlined.Bookmarks\n    val Check = Icons.Rounded.Check\n    val Close = Icons.Rounded.Close\n    val Grid3x3 = Icons.Rounded.Grid3x3\n    val MoreVert = Icons.Default.MoreVert\n    val Person = Icons.Rounded.Person\n    val Search = Icons.Rounded.Search\n    val Settings = Icons.Rounded.Settings\n    val ShortText = Icons.AutoMirrored.Rounded.ShortText\n    val Upcoming = Icons.Rounded.Upcoming\n    val UpcomingBorder = Icons.Outlined.Upcoming\n    val ViewDay = Icons.Rounded.ViewDay\n}\n"
  },
  {
    "path": "core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/theme/Background.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem.theme\n\nimport androidx.compose.runtime.Immutable\nimport androidx.compose.runtime.staticCompositionLocalOf\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.unit.Dp\n\n/**\n * A class to model background color and tonal elevation values for Now in Android.\n */\n@Immutable\ndata class BackgroundTheme(\n    val color: Color = Color.Unspecified,\n    val tonalElevation: Dp = Dp.Unspecified,\n)\n\n/**\n * A composition local for [BackgroundTheme].\n */\nval LocalBackgroundTheme = staticCompositionLocalOf { BackgroundTheme() }\n"
  },
  {
    "path": "core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/theme/Color.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem.theme\n\nimport androidx.compose.ui.graphics.Color\n\n/**\n * Now in Android colors.\n */\ninternal val Blue10 = Color(0xFF001F28)\ninternal val Blue20 = Color(0xFF003544)\ninternal val Blue30 = Color(0xFF004D61)\ninternal val Blue40 = Color(0xFF006780)\ninternal val Blue80 = Color(0xFF5DD5FC)\ninternal val Blue90 = Color(0xFFB8EAFF)\ninternal val DarkGreen10 = Color(0xFF0D1F12)\ninternal val DarkGreen20 = Color(0xFF223526)\ninternal val DarkGreen30 = Color(0xFF394B3C)\ninternal val DarkGreen40 = Color(0xFF4F6352)\ninternal val DarkGreen80 = Color(0xFFB7CCB8)\ninternal val DarkGreen90 = Color(0xFFD3E8D3)\ninternal val DarkGreenGray10 = Color(0xFF1A1C1A)\ninternal val DarkGreenGray20 = Color(0xFF2F312E)\ninternal val DarkGreenGray90 = Color(0xFFE2E3DE)\ninternal val DarkGreenGray95 = Color(0xFFF0F1EC)\ninternal val DarkGreenGray99 = Color(0xFFFBFDF7)\ninternal val DarkPurpleGray10 = Color(0xFF201A1B)\ninternal val DarkPurpleGray20 = Color(0xFF362F30)\ninternal val DarkPurpleGray90 = Color(0xFFECDFE0)\ninternal val DarkPurpleGray95 = Color(0xFFFAEEEF)\ninternal val DarkPurpleGray99 = Color(0xFFFCFCFC)\ninternal val Green10 = Color(0xFF00210B)\ninternal val Green20 = Color(0xFF003919)\ninternal val Green30 = Color(0xFF005227)\ninternal val Green40 = Color(0xFF006D36)\ninternal val Green80 = Color(0xFF0EE37C)\ninternal val Green90 = Color(0xFF5AFF9D)\ninternal val GreenGray30 = Color(0xFF414941)\ninternal val GreenGray50 = Color(0xFF727971)\ninternal val GreenGray60 = Color(0xFF8B938A)\ninternal val GreenGray80 = Color(0xFFC1C9BF)\ninternal val GreenGray90 = Color(0xFFDDE5DB)\ninternal val Orange10 = Color(0xFF380D00)\ninternal val Orange20 = Color(0xFF5B1A00)\ninternal val Orange30 = Color(0xFF812800)\ninternal val Orange40 = Color(0xFFA23F16)\ninternal val Orange80 = Color(0xFFFFB59B)\ninternal val Orange90 = Color(0xFFFFDBCF)\ninternal val Purple10 = Color(0xFF36003C)\ninternal val Purple20 = Color(0xFF560A5D)\ninternal val Purple30 = Color(0xFF702776)\ninternal val Purple40 = Color(0xFF8B418F)\ninternal val Purple80 = Color(0xFFFFA9FE)\ninternal val Purple90 = Color(0xFFFFD6FA)\ninternal val PurpleGray30 = Color(0xFF4D444C)\ninternal val PurpleGray50 = Color(0xFF7F747C)\ninternal val PurpleGray60 = Color(0xFF998D96)\ninternal val PurpleGray80 = Color(0xFFD0C3CC)\ninternal val PurpleGray90 = Color(0xFFEDDEE8)\ninternal val Red10 = Color(0xFF410002)\ninternal val Red20 = Color(0xFF690005)\ninternal val Red30 = Color(0xFF93000A)\ninternal val Red40 = Color(0xFFBA1A1A)\ninternal val Red80 = Color(0xFFFFB4AB)\ninternal val Red90 = Color(0xFFFFDAD6)\ninternal val Teal10 = Color(0xFF001F26)\ninternal val Teal20 = Color(0xFF02363F)\ninternal val Teal30 = Color(0xFF214D56)\ninternal val Teal40 = Color(0xFF3A656F)\ninternal val Teal80 = Color(0xFFA2CED9)\ninternal val Teal90 = Color(0xFFBEEAF6)\n"
  },
  {
    "path": "core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/theme/Gradient.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem.theme\n\nimport androidx.compose.runtime.Immutable\nimport androidx.compose.runtime.staticCompositionLocalOf\nimport androidx.compose.ui.graphics.Color\n\n/**\n * A class to model gradient color values for Now in Android.\n *\n * @param top The top gradient color to be rendered.\n * @param bottom The bottom gradient color to be rendered.\n * @param container The container gradient color over which the gradient will be rendered.\n */\n@Immutable\ndata class GradientColors(\n    val top: Color = Color.Unspecified,\n    val bottom: Color = Color.Unspecified,\n    val container: Color = Color.Unspecified,\n)\n\n/**\n * A composition local for [GradientColors].\n */\nval LocalGradientColors = staticCompositionLocalOf { GradientColors() }\n"
  },
  {
    "path": "core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/theme/Theme.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem.theme\n\nimport android.os.Build\nimport androidx.annotation.ChecksSdkIntAtLeast\nimport androidx.annotation.VisibleForTesting\nimport androidx.compose.foundation.isSystemInDarkTheme\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.darkColorScheme\nimport androidx.compose.material3.dynamicDarkColorScheme\nimport androidx.compose.material3.dynamicLightColorScheme\nimport androidx.compose.material3.lightColorScheme\nimport androidx.compose.material3.surfaceColorAtElevation\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.CompositionLocalProvider\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.unit.dp\n\n/**\n * Light default theme color scheme\n */\n@VisibleForTesting\nval LightDefaultColorScheme = lightColorScheme(\n    primary = Purple40,\n    onPrimary = Color.White,\n    primaryContainer = Purple90,\n    onPrimaryContainer = Purple10,\n    secondary = Orange40,\n    onSecondary = Color.White,\n    secondaryContainer = Orange90,\n    onSecondaryContainer = Orange10,\n    tertiary = Blue40,\n    onTertiary = Color.White,\n    tertiaryContainer = Blue90,\n    onTertiaryContainer = Blue10,\n    error = Red40,\n    onError = Color.White,\n    errorContainer = Red90,\n    onErrorContainer = Red10,\n    background = DarkPurpleGray99,\n    onBackground = DarkPurpleGray10,\n    surface = DarkPurpleGray99,\n    onSurface = DarkPurpleGray10,\n    surfaceVariant = PurpleGray90,\n    onSurfaceVariant = PurpleGray30,\n    inverseSurface = DarkPurpleGray20,\n    inverseOnSurface = DarkPurpleGray95,\n    outline = PurpleGray50,\n)\n\n/**\n * Dark default theme color scheme\n */\n@VisibleForTesting\nval DarkDefaultColorScheme = darkColorScheme(\n    primary = Purple80,\n    onPrimary = Purple20,\n    primaryContainer = Purple30,\n    onPrimaryContainer = Purple90,\n    secondary = Orange80,\n    onSecondary = Orange20,\n    secondaryContainer = Orange30,\n    onSecondaryContainer = Orange90,\n    tertiary = Blue80,\n    onTertiary = Blue20,\n    tertiaryContainer = Blue30,\n    onTertiaryContainer = Blue90,\n    error = Red80,\n    onError = Red20,\n    errorContainer = Red30,\n    onErrorContainer = Red90,\n    background = DarkPurpleGray10,\n    onBackground = DarkPurpleGray90,\n    surface = DarkPurpleGray10,\n    onSurface = DarkPurpleGray90,\n    surfaceVariant = PurpleGray30,\n    onSurfaceVariant = PurpleGray80,\n    inverseSurface = DarkPurpleGray90,\n    inverseOnSurface = DarkPurpleGray10,\n    outline = PurpleGray60,\n)\n\n/**\n * Light Android theme color scheme\n */\n@VisibleForTesting\nval LightAndroidColorScheme = lightColorScheme(\n    primary = Green40,\n    onPrimary = Color.White,\n    primaryContainer = Green90,\n    onPrimaryContainer = Green10,\n    secondary = DarkGreen40,\n    onSecondary = Color.White,\n    secondaryContainer = DarkGreen90,\n    onSecondaryContainer = DarkGreen10,\n    tertiary = Teal40,\n    onTertiary = Color.White,\n    tertiaryContainer = Teal90,\n    onTertiaryContainer = Teal10,\n    error = Red40,\n    onError = Color.White,\n    errorContainer = Red90,\n    onErrorContainer = Red10,\n    background = DarkGreenGray99,\n    onBackground = DarkGreenGray10,\n    surface = DarkGreenGray99,\n    onSurface = DarkGreenGray10,\n    surfaceVariant = GreenGray90,\n    onSurfaceVariant = GreenGray30,\n    inverseSurface = DarkGreenGray20,\n    inverseOnSurface = DarkGreenGray95,\n    outline = GreenGray50,\n)\n\n/**\n * Dark Android theme color scheme\n */\n@VisibleForTesting\nval DarkAndroidColorScheme = darkColorScheme(\n    primary = Green80,\n    onPrimary = Green20,\n    primaryContainer = Green30,\n    onPrimaryContainer = Green90,\n    secondary = DarkGreen80,\n    onSecondary = DarkGreen20,\n    secondaryContainer = DarkGreen30,\n    onSecondaryContainer = DarkGreen90,\n    tertiary = Teal80,\n    onTertiary = Teal20,\n    tertiaryContainer = Teal30,\n    onTertiaryContainer = Teal90,\n    error = Red80,\n    onError = Red20,\n    errorContainer = Red30,\n    onErrorContainer = Red90,\n    background = DarkGreenGray10,\n    onBackground = DarkGreenGray90,\n    surface = DarkGreenGray10,\n    onSurface = DarkGreenGray90,\n    surfaceVariant = GreenGray30,\n    onSurfaceVariant = GreenGray80,\n    inverseSurface = DarkGreenGray90,\n    inverseOnSurface = DarkGreenGray10,\n    outline = GreenGray60,\n)\n\n/**\n * Light Android gradient colors\n */\nval LightAndroidGradientColors = GradientColors(container = DarkGreenGray95)\n\n/**\n * Dark Android gradient colors\n */\nval DarkAndroidGradientColors = GradientColors(container = Color.Black)\n\n/**\n * Light Android background theme\n */\nval LightAndroidBackgroundTheme = BackgroundTheme(color = DarkGreenGray95)\n\n/**\n * Dark Android background theme\n */\nval DarkAndroidBackgroundTheme = BackgroundTheme(color = Color.Black)\n\n/**\n * Now in Android theme.\n *\n * @param darkTheme Whether the theme should use a dark color scheme (follows system by default).\n * @param androidTheme Whether the theme should use the Android theme color scheme instead of the\n *        default theme.\n * @param disableDynamicTheming If `true`, disables the use of dynamic theming, even when it is\n *        supported. This parameter has no effect if [androidTheme] is `true`.\n */\n@Composable\nfun NiaTheme(\n    darkTheme: Boolean = isSystemInDarkTheme(),\n    androidTheme: Boolean = false,\n    disableDynamicTheming: Boolean = true,\n    content: @Composable () -> Unit,\n) {\n    // Color scheme\n    val colorScheme = when {\n        androidTheme -> if (darkTheme) DarkAndroidColorScheme else LightAndroidColorScheme\n        !disableDynamicTheming && supportsDynamicTheming() -> {\n            val context = LocalContext.current\n            if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)\n        }\n\n        else -> if (darkTheme) DarkDefaultColorScheme else LightDefaultColorScheme\n    }\n    // Gradient colors\n    val emptyGradientColors = GradientColors(container = colorScheme.surfaceColorAtElevation(2.dp))\n    val defaultGradientColors = GradientColors(\n        top = colorScheme.inverseOnSurface,\n        bottom = colorScheme.primaryContainer,\n        container = colorScheme.surface,\n    )\n    val gradientColors = when {\n        androidTheme -> if (darkTheme) DarkAndroidGradientColors else LightAndroidGradientColors\n        !disableDynamicTheming && supportsDynamicTheming() -> emptyGradientColors\n        else -> defaultGradientColors\n    }\n    // Background theme\n    val defaultBackgroundTheme = BackgroundTheme(\n        color = colorScheme.surface,\n        tonalElevation = 2.dp,\n    )\n    val backgroundTheme = when {\n        androidTheme -> if (darkTheme) DarkAndroidBackgroundTheme else LightAndroidBackgroundTheme\n        else -> defaultBackgroundTheme\n    }\n    val tintTheme = when {\n        androidTheme -> TintTheme()\n        !disableDynamicTheming && supportsDynamicTheming() -> TintTheme(colorScheme.primary)\n        else -> TintTheme()\n    }\n    // Composition locals\n    CompositionLocalProvider(\n        LocalGradientColors provides gradientColors,\n        LocalBackgroundTheme provides backgroundTheme,\n        LocalTintTheme provides tintTheme,\n    ) {\n        MaterialTheme(\n            colorScheme = colorScheme,\n            typography = NiaTypography,\n            content = content,\n        )\n    }\n}\n\n@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S)\nfun supportsDynamicTheming() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S\n"
  },
  {
    "path": "core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/theme/Tint.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem.theme\n\nimport androidx.compose.runtime.Immutable\nimport androidx.compose.runtime.staticCompositionLocalOf\nimport androidx.compose.ui.graphics.Color\n\n/**\n * A class to model background color and tonal elevation values for Now in Android.\n */\n@Immutable\ndata class TintTheme(\n    val iconTint: Color = Color.Unspecified,\n)\n\n/**\n * A composition local for [TintTheme].\n */\nval LocalTintTheme = staticCompositionLocalOf { TintTheme() }\n"
  },
  {
    "path": "core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/theme/Type.kt",
    "content": "/*\n * Copyright 2025 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem.theme\n\nimport androidx.compose.material3.Typography\nimport androidx.compose.ui.text.TextStyle\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.text.style.LineHeightStyle\nimport androidx.compose.ui.text.style.LineHeightStyle.Alignment\nimport androidx.compose.ui.text.style.LineHeightStyle.Trim\nimport androidx.compose.ui.text.style.TextAlign\nimport androidx.compose.ui.text.style.TextDirection\nimport androidx.compose.ui.unit.sp\n\n/**\n * Now in Android typography.\n */\ninternal val NiaTypography = Typography(\n    displayLarge = TextStyle(\n        fontWeight = FontWeight.Normal,\n        fontSize = 57.sp,\n        lineHeight = 64.sp,\n        letterSpacing = (-0.25).sp,\n        textDirection = TextDirection.Ltr,\n        textAlign = TextAlign.Left,\n    ),\n    displayMedium = TextStyle(\n        fontWeight = FontWeight.Normal,\n        fontSize = 45.sp,\n        lineHeight = 52.sp,\n        letterSpacing = 0.sp,\n        textDirection = TextDirection.Ltr,\n        textAlign = TextAlign.Left,\n    ),\n    displaySmall = TextStyle(\n        fontWeight = FontWeight.Normal,\n        fontSize = 36.sp,\n        lineHeight = 44.sp,\n        letterSpacing = 0.sp,\n        textDirection = TextDirection.Ltr,\n        textAlign = TextAlign.Left,\n    ),\n    headlineLarge = TextStyle(\n        fontWeight = FontWeight.Normal,\n        fontSize = 32.sp,\n        lineHeight = 40.sp,\n        letterSpacing = 0.sp,\n        textDirection = TextDirection.Ltr,\n        textAlign = TextAlign.Left,\n    ),\n    headlineMedium = TextStyle(\n        fontWeight = FontWeight.Normal,\n        fontSize = 28.sp,\n        lineHeight = 36.sp,\n        letterSpacing = 0.sp,\n        textDirection = TextDirection.Ltr,\n        textAlign = TextAlign.Left,\n    ),\n    headlineSmall = TextStyle(\n        fontWeight = FontWeight.Normal,\n        fontSize = 24.sp,\n        lineHeight = 32.sp,\n        letterSpacing = 0.sp,\n        lineHeightStyle = LineHeightStyle(\n            alignment = Alignment.Bottom,\n            trim = Trim.None,\n        ),\n        textDirection = TextDirection.Ltr,\n        textAlign = TextAlign.Left,\n    ),\n    titleLarge = TextStyle(\n        fontWeight = FontWeight.Bold,\n        fontSize = 22.sp,\n        lineHeight = 28.sp,\n        letterSpacing = 0.sp,\n        lineHeightStyle = LineHeightStyle(\n            alignment = Alignment.Bottom,\n            trim = Trim.LastLineBottom,\n        ),\n        textDirection = TextDirection.Ltr,\n        textAlign = TextAlign.Left,\n    ),\n    titleMedium = TextStyle(\n        fontWeight = FontWeight.Bold,\n        fontSize = 18.sp,\n        lineHeight = 24.sp,\n        letterSpacing = 0.1.sp,\n        textDirection = TextDirection.Ltr,\n        textAlign = TextAlign.Left,\n    ),\n    titleSmall = TextStyle(\n        fontWeight = FontWeight.Medium,\n        fontSize = 14.sp,\n        lineHeight = 20.sp,\n        letterSpacing = 0.1.sp,\n        textDirection = TextDirection.Ltr,\n        textAlign = TextAlign.Left,\n    ),\n    // Default text style\n    bodyLarge = TextStyle(\n        fontWeight = FontWeight.Normal,\n        fontSize = 16.sp,\n        lineHeight = 24.sp,\n        letterSpacing = 0.5.sp,\n        lineHeightStyle = LineHeightStyle(\n            alignment = Alignment.Center,\n            trim = Trim.None,\n        ),\n        textDirection = TextDirection.Ltr,\n        textAlign = TextAlign.Left,\n    ),\n    bodyMedium = TextStyle(\n        fontWeight = FontWeight.Normal,\n        fontSize = 14.sp,\n        lineHeight = 20.sp,\n        letterSpacing = 0.25.sp,\n        textDirection = TextDirection.Ltr,\n        textAlign = TextAlign.Left,\n    ),\n    bodySmall = TextStyle(\n        fontWeight = FontWeight.Normal,\n        fontSize = 12.sp,\n        lineHeight = 16.sp,\n        letterSpacing = 0.4.sp,\n        textDirection = TextDirection.Ltr,\n        textAlign = TextAlign.Left,\n    ),\n    // Used for Button\n    labelLarge = TextStyle(\n        fontWeight = FontWeight.Medium,\n        fontSize = 14.sp,\n        lineHeight = 20.sp,\n        letterSpacing = 0.1.sp,\n        lineHeightStyle = LineHeightStyle(\n            alignment = Alignment.Center,\n            trim = Trim.LastLineBottom,\n        ),\n        textDirection = TextDirection.Ltr,\n        textAlign = TextAlign.Left,\n    ),\n    // Used for Navigation items\n    labelMedium = TextStyle(\n        fontWeight = FontWeight.Medium,\n        fontSize = 12.sp,\n        lineHeight = 16.sp,\n        letterSpacing = 0.5.sp,\n        lineHeightStyle = LineHeightStyle(\n            alignment = Alignment.Center,\n            trim = Trim.LastLineBottom,\n        ),\n        textDirection = TextDirection.Ltr,\n        textAlign = TextAlign.Left,\n    ),\n    // Used for Tag\n    labelSmall = TextStyle(\n        fontWeight = FontWeight.Medium,\n        fontSize = 10.sp,\n        lineHeight = 14.sp,\n        letterSpacing = 0.sp,\n        lineHeightStyle = LineHeightStyle(\n            alignment = Alignment.Center,\n            trim = Trim.LastLineBottom,\n        ),\n        textDirection = TextDirection.Ltr,\n        textAlign = TextAlign.Left,\n    ),\n)\n"
  },
  {
    "path": "core/designsystem/src/main/res/drawable/core_designsystem_ic_placeholder_default.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    xmlns:aapt=\"http://schemas.android.com/aapt\"\n    android:width=\"364dp\"\n    android:height=\"182dp\"\n    android:viewportWidth=\"364\"\n    android:viewportHeight=\"182\">\n  <path\n      android:pathData=\"M0,0h364v182h-364z\"\n      android:fillColor=\"#FCFCFC\"/>\n  <path\n      android:pathData=\"M0,0h364v182h-364z\"\n      android:fillColor=\"#7E7576\"\n      android:fillAlpha=\"0.02\"/>\n  <path\n      android:pathData=\"M0,0h364v182h-364z\"\n      android:fillColor=\"#8C4190\"\n      android:fillAlpha=\"0.11\"/>\n  <path\n      android:pathData=\"M171,119h155a25,25 0,0 1,25 25,25 25,0 0,1 -25,25H171a25,25 0,0 1,-25 -25,25 25,0 0,1 25,-25z\"\n      android:strokeWidth=\"2\"\n      android:fillColor=\"#00000000\"\n      android:strokeColor=\"#5DD4FB\"/>\n  <path\n      android:pathData=\"M156.02,33.7a35,35 0,0 0,-2.69 13.46L224,47.16a35,35 0,0 0,-10.35 -24.86A35.34,35.34 0,0 0,188.67 12a35.48,35.48 0,0 0,-24.99 10.3,35.15 35.15,0 0,0 -7.66,11.4ZM153.33,47.16c0,4.62 -0.92,9.19 -2.72,13.46a35.13,35.13 0,0 1,-7.73 11.4,35.74 35.74,0 0,1 -11.58,7.63 36.17,36.17 0,0 1,-38.9 -7.63,35.13 35.13,0 0,1 -7.75,-11.4 34.7,34.7 0,0 1,-2.71 -13.46h71.4ZM12,47.16A35.33,35.33 0,0 1,22.24 22.3,34.96 34.96,0 0,1 46.97,12a34.8,34.8 0,0 1,24.72 10.3,35.19 35.19,0 0,1 10.25,24.86L12,47.16Z\"\n      android:fillType=\"evenOdd\">\n    <aapt:attr name=\"android:fillColor\">\n      <gradient \n          android:startY=\"11.9999\"\n          android:startX=\"224\"\n          android:endY=\"138.703\"\n          android:endX=\"181.972\"\n          android:type=\"linear\">\n        <item android:offset=\"0\" android:color=\"#FFFFA8FF\"/>\n        <item android:offset=\"1\" android:color=\"#FFFF8B5E\"/>\n      </gradient>\n    </aapt:attr>\n  </path>\n  <path\n      android:strokeWidth=\"1\"\n      android:pathData=\"M317.07,82.14V47.1l-7.3,34.27 7.29,-34.27 -14.25,32 14.24,-32 -20.6,28.34 20.6,-28.35L291,70.53l26.03,-23.44L286.7,64.6l30.34,-17.52 -33.32,10.83 33.31,-10.83 -34.84,3.65 34.84,-3.66 -34.84,-3.67 34.84,3.66 -33.31,-10.83 33.32,10.82 -30.34,-17.52 30.34,17.52 -26.03,-23.45 26.04,23.44 -20.6,-28.34 20.6,28.34 -14.25,-32 14.26,32 -7.28,-34.27 7.29,34.26V12v35.03l7.29,-34.26 -7.28,34.27 14.25,-32 -14.24,32 20.6,-28.34 -20.6,28.34 26.04,-23.44 -26.03,23.45 30.34,-17.52 -30.34,17.52 33.32,-10.82 -33.32,10.83 34.84,-3.66 -34.84,3.67 34.84,3.66 -34.84,-3.65 33.32,10.83 -33.32,-10.83 30.34,17.52L317.1,47.1l26.03,23.44 -26.04,-23.44 20.6,28.35 -20.6,-28.34 14.24,32 -14.25,-32 7.28,34.27 -7.29,-34.27v35.04Z\"\n      android:strokeLineJoin=\"round\"\n      android:fillColor=\"#00000000\"\n      android:strokeColor=\"#FF8B5E\"/>\n  <path\n      android:pathData=\"M38.2,170h0.77a26.2,26.2 89.06,0 0,26.21 -26.2,26.2 26.2,89.06 0,0 -26.2,-26.21h-0.77A26.2,26.2 89.06,0 0,12 143.79,26.2 26.2,89.06 0,0 38.2,170z\"\n      android:fillColor=\"#FFA8FF\"/>\n</vector>\n"
  },
  {
    "path": "core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/BackgroundScreenshotTests.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem\n\nimport androidx.activity.ComponentActivity\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.material3.Text\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.test.junit4.createAndroidComposeRule\nimport androidx.compose.ui.unit.dp\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaGradientBackground\nimport com.google.samples.apps.nowinandroid.core.testing.util.captureMultiTheme\nimport dagger.hilt.android.testing.HiltTestApplication\nimport org.junit.Rule\nimport org.junit.Test\nimport org.junit.runner.RunWith\nimport org.robolectric.RobolectricTestRunner\nimport org.robolectric.annotation.Config\nimport org.robolectric.annotation.GraphicsMode\nimport org.robolectric.annotation.LooperMode\n\n@RunWith(RobolectricTestRunner::class)\n@GraphicsMode(GraphicsMode.Mode.NATIVE)\n@Config(application = HiltTestApplication::class, qualifiers = \"480dpi\")\n@LooperMode(LooperMode.Mode.PAUSED)\nclass BackgroundScreenshotTests {\n\n    @get:Rule\n    val composeTestRule = createAndroidComposeRule<ComponentActivity>()\n\n    @Test\n    fun niaBackground_multipleThemes() {\n        composeTestRule.captureMultiTheme(\"Background\") { description ->\n            NiaBackground(Modifier.size(100.dp)) {\n                Text(\"$description background\")\n            }\n        }\n    }\n\n    @Test\n    fun niaGradientBackground_multipleThemes() {\n        composeTestRule.captureMultiTheme(\"Background\", \"GradientBackground\") { description ->\n            NiaGradientBackground(Modifier.size(100.dp)) {\n                Text(\"$description background\")\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/ButtonScreenshotTests.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem\n\nimport androidx.activity.ComponentActivity\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.Surface\nimport androidx.compose.material3.Text\nimport androidx.compose.ui.test.junit4.createAndroidComposeRule\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaButton\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaOutlinedButton\nimport com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons\nimport com.google.samples.apps.nowinandroid.core.testing.util.captureMultiTheme\nimport dagger.hilt.android.testing.HiltTestApplication\nimport org.junit.Rule\nimport org.junit.Test\nimport org.junit.runner.RunWith\nimport org.robolectric.RobolectricTestRunner\nimport org.robolectric.annotation.Config\nimport org.robolectric.annotation.GraphicsMode\nimport org.robolectric.annotation.LooperMode\n\n@RunWith(RobolectricTestRunner::class)\n@GraphicsMode(GraphicsMode.Mode.NATIVE)\n@Config(application = HiltTestApplication::class, qualifiers = \"480dpi\")\n@LooperMode(LooperMode.Mode.PAUSED)\nclass ButtonScreenshotTests {\n\n    @get:Rule\n    val composeTestRule = createAndroidComposeRule<ComponentActivity>()\n\n    @Test\n    fun niaButton_multipleThemes() {\n        composeTestRule.captureMultiTheme(\"Button\") { description ->\n            Surface {\n                NiaButton(onClick = {}, text = { Text(\"$description Button\") })\n            }\n        }\n    }\n\n    @Test\n    fun niaOutlineButton_multipleThemes() {\n        composeTestRule.captureMultiTheme(\"Button\", \"OutlineButton\") { description ->\n            Surface {\n                NiaOutlinedButton(onClick = {}, text = { Text(\"$description OutlineButton\") })\n            }\n        }\n    }\n\n    @Test\n    fun niaButton_leadingIcon_multipleThemes() {\n        composeTestRule.captureMultiTheme(\n            name = \"Button\",\n            overrideFileName = \"ButtonLeadingIcon\",\n            shouldCompareAndroidTheme = false,\n        ) { description ->\n            Surface {\n                NiaButton(\n                    onClick = {},\n                    text = { Text(\"$description Icon Button\") },\n                    leadingIcon = { Icon(imageVector = NiaIcons.Add, contentDescription = null) },\n                )\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/FilterChipScreenshotTests.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem\n\nimport androidx.activity.ComponentActivity\nimport androidx.compose.material3.Surface\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.CompositionLocalProvider\nimport androidx.compose.ui.platform.LocalInspectionMode\nimport androidx.compose.ui.test.DeviceConfigurationOverride\nimport androidx.compose.ui.test.FontScale\nimport androidx.compose.ui.test.ForcedSize\nimport androidx.compose.ui.test.junit4.createAndroidComposeRule\nimport androidx.compose.ui.test.onRoot\nimport androidx.compose.ui.test.then\nimport androidx.compose.ui.unit.DpSize\nimport androidx.compose.ui.unit.dp\nimport com.github.takahirom.roborazzi.captureRoboImage\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilterChip\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\nimport com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions\nimport com.google.samples.apps.nowinandroid.core.testing.util.captureMultiTheme\nimport dagger.hilt.android.testing.HiltTestApplication\nimport org.junit.Rule\nimport org.junit.Test\nimport org.junit.runner.RunWith\nimport org.robolectric.RobolectricTestRunner\nimport org.robolectric.annotation.Config\nimport org.robolectric.annotation.GraphicsMode\nimport org.robolectric.annotation.LooperMode\n\n@RunWith(RobolectricTestRunner::class)\n@GraphicsMode(GraphicsMode.Mode.NATIVE)\n@Config(application = HiltTestApplication::class, qualifiers = \"480dpi\")\n@LooperMode(LooperMode.Mode.PAUSED)\nclass FilterChipScreenshotTests {\n\n    @get:Rule\n    val composeTestRule = createAndroidComposeRule<ComponentActivity>()\n\n    @Test\n    fun filterChip_multipleThemes() {\n        composeTestRule.captureMultiTheme(\"FilterChip\") {\n            Surface {\n                NiaFilterChip(selected = false, onSelectedChange = {}) {\n                    Text(\"Unselected chip\")\n                }\n            }\n        }\n    }\n\n    @Test\n    fun filterChip_multipleThemes_selected() {\n        composeTestRule.captureMultiTheme(\"FilterChip\", \"FilterChipSelected\") {\n            Surface {\n                NiaFilterChip(selected = true, onSelectedChange = {}) {\n                    Text(\"Selected Chip\")\n                }\n            }\n        }\n    }\n\n    @Test\n    fun filterChip_hugeFont() {\n        composeTestRule.setContent {\n            CompositionLocalProvider(\n                LocalInspectionMode provides true,\n            ) {\n                DeviceConfigurationOverride(\n                    DeviceConfigurationOverride.FontScale(2f) then\n                        DeviceConfigurationOverride.ForcedSize(DpSize(80.dp, 40.dp)),\n                ) {\n                    NiaTheme {\n                        NiaBackground {\n                            NiaFilterChip(selected = true, onSelectedChange = {}) {\n                                Text(\"Chip\")\n                            }\n                        }\n                    }\n                }\n            }\n        }\n        composeTestRule.onRoot()\n            .captureRoboImage(\n                \"src/test/screenshots/FilterChip/FilterChip_fontScale2.png\",\n                roborazziOptions = DefaultRoborazziOptions,\n            )\n    }\n}\n"
  },
  {
    "path": "core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/IconButtonScreenshotTests.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem\n\nimport androidx.activity.ComponentActivity\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.Surface\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.test.junit4.createAndroidComposeRule\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton\nimport com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons\nimport com.google.samples.apps.nowinandroid.core.testing.util.captureMultiTheme\nimport dagger.hilt.android.testing.HiltTestApplication\nimport org.junit.Rule\nimport org.junit.Test\nimport org.junit.runner.RunWith\nimport org.robolectric.RobolectricTestRunner\nimport org.robolectric.annotation.Config\nimport org.robolectric.annotation.GraphicsMode\nimport org.robolectric.annotation.LooperMode\n\n@RunWith(RobolectricTestRunner::class)\n@GraphicsMode(GraphicsMode.Mode.NATIVE)\n@Config(application = HiltTestApplication::class, qualifiers = \"480dpi\")\n@LooperMode(LooperMode.Mode.PAUSED)\nclass IconButtonScreenshotTests {\n\n    @get:Rule\n    val composeTestRule = createAndroidComposeRule<ComponentActivity>()\n\n    @Test\n    fun iconButton_multipleThemes() {\n        composeTestRule.captureMultiTheme(\"IconButton\") {\n            NiaIconToggleExample(false)\n        }\n    }\n\n    @Test\n    fun iconButton_unchecked_multipleThemes() {\n        composeTestRule.captureMultiTheme(\"IconButton\", \"IconButtonUnchecked\") {\n            Surface {\n                NiaIconToggleExample(true)\n            }\n        }\n    }\n\n    @Composable\n    private fun NiaIconToggleExample(checked: Boolean) {\n        NiaIconToggleButton(\n            checked = checked,\n            onCheckedChange = { },\n            icon = {\n                Icon(\n                    imageVector = NiaIcons.BookmarkBorder,\n                    contentDescription = null,\n                )\n            },\n            checkedIcon = {\n                Icon(\n                    imageVector = NiaIcons.Bookmark,\n                    contentDescription = null,\n                )\n            },\n        )\n    }\n}\n"
  },
  {
    "path": "core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/LoadingWheelScreenshotTests.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem\n\nimport androidx.activity.ComponentActivity\nimport androidx.compose.material3.Surface\nimport androidx.compose.ui.test.junit4.createAndroidComposeRule\nimport androidx.compose.ui.test.onRoot\nimport com.github.takahirom.roborazzi.captureRoboImage\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaOverlayLoadingWheel\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\nimport com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions\nimport com.google.samples.apps.nowinandroid.core.testing.util.captureMultiTheme\nimport dagger.hilt.android.testing.HiltTestApplication\nimport org.junit.Rule\nimport org.junit.Test\nimport org.junit.runner.RunWith\nimport org.robolectric.RobolectricTestRunner\nimport org.robolectric.annotation.Config\nimport org.robolectric.annotation.GraphicsMode\nimport org.robolectric.annotation.LooperMode\n\n@RunWith(RobolectricTestRunner::class)\n@GraphicsMode(GraphicsMode.Mode.NATIVE)\n@Config(application = HiltTestApplication::class, qualifiers = \"480dpi\")\n@LooperMode(LooperMode.Mode.PAUSED)\nclass LoadingWheelScreenshotTests {\n\n    @get:Rule\n    val composeTestRule = createAndroidComposeRule<ComponentActivity>()\n\n    @Test\n    fun loadingWheel_multipleThemes() {\n        composeTestRule.captureMultiTheme(\"LoadingWheel\") {\n            Surface {\n                NiaLoadingWheel(contentDesc = \"test\")\n            }\n        }\n    }\n\n    @Test\n    fun overlayLoadingWheel_multipleThemes() {\n        composeTestRule.captureMultiTheme(\"LoadingWheel\", \"OverlayLoadingWheel\") {\n            Surface {\n                NiaOverlayLoadingWheel(contentDesc = \"test\")\n            }\n        }\n    }\n\n    @Test\n    fun loadingWheelAnimation() {\n        composeTestRule.mainClock.autoAdvance = false\n        composeTestRule.setContent {\n            NiaTheme {\n                NiaLoadingWheel(contentDesc = \"\")\n            }\n        }\n        // Try multiple frames of the animation; some arbitrary, some synchronized with duration.\n        listOf(20L, 115L, 724L, 1000L).forEach { deltaTime ->\n            composeTestRule.mainClock.advanceTimeBy(deltaTime)\n            composeTestRule.onRoot()\n                .captureRoboImage(\n                    \"src/test/screenshots/LoadingWheel/LoadingWheel_animation_$deltaTime.png\",\n                    roborazziOptions = DefaultRoborazziOptions,\n                )\n        }\n    }\n}\n"
  },
  {
    "path": "core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/NavigationScreenshotTests.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem\n\nimport androidx.activity.ComponentActivity\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.Surface\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.CompositionLocalProvider\nimport androidx.compose.ui.platform.LocalInspectionMode\nimport androidx.compose.ui.test.DeviceConfigurationOverride\nimport androidx.compose.ui.test.FontScale\nimport androidx.compose.ui.test.junit4.createAndroidComposeRule\nimport androidx.compose.ui.test.onRoot\nimport com.github.takahirom.roborazzi.captureRoboImage\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBar\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBarItem\nimport com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\nimport com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions\nimport com.google.samples.apps.nowinandroid.core.testing.util.captureMultiTheme\nimport dagger.hilt.android.testing.HiltTestApplication\nimport org.junit.Rule\nimport org.junit.Test\nimport org.junit.runner.RunWith\nimport org.robolectric.RobolectricTestRunner\nimport org.robolectric.annotation.Config\nimport org.robolectric.annotation.GraphicsMode\nimport org.robolectric.annotation.LooperMode\n\n@RunWith(RobolectricTestRunner::class)\n@GraphicsMode(GraphicsMode.Mode.NATIVE)\n@Config(application = HiltTestApplication::class, qualifiers = \"480dpi\")\n@LooperMode(LooperMode.Mode.PAUSED)\nclass NavigationScreenshotTests {\n\n    @get:Rule\n    val composeTestRule = createAndroidComposeRule<ComponentActivity>()\n\n    @Test\n    fun navigation_multipleThemes() {\n        composeTestRule.captureMultiTheme(\"Navigation\") {\n            Surface {\n                NiaNavigationBarExample()\n            }\n        }\n    }\n\n    @Test\n    fun navigation_hugeFont() {\n        composeTestRule.setContent {\n            CompositionLocalProvider(\n                LocalInspectionMode provides true,\n            ) {\n                DeviceConfigurationOverride(\n                    DeviceConfigurationOverride.FontScale(2f),\n                ) {\n                    NiaTheme {\n                        NiaNavigationBarExample(\"Looong item\")\n                    }\n                }\n            }\n        }\n        composeTestRule.onRoot()\n            .captureRoboImage(\n                \"src/test/screenshots/Navigation\" +\n                    \"/Navigation_fontScale2.png\",\n                roborazziOptions = DefaultRoborazziOptions,\n            )\n    }\n\n    @Composable\n    private fun NiaNavigationBarExample(label: String = \"Item\") {\n        NiaNavigationBar {\n            (0..2).forEach { index ->\n                NiaNavigationBarItem(\n                    icon = {\n                        Icon(\n                            imageVector = NiaIcons.UpcomingBorder,\n                            contentDescription = \"\",\n                        )\n                    },\n                    selectedIcon = {\n                        Icon(\n                            imageVector = NiaIcons.Upcoming,\n                            contentDescription = \"\",\n                        )\n                    },\n                    label = { Text(label) },\n                    selected = index == 0,\n                    onClick = { },\n                )\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/TabsScreenshotTests.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem\n\nimport androidx.activity.ComponentActivity\nimport androidx.compose.material3.Surface\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.CompositionLocalProvider\nimport androidx.compose.ui.platform.LocalInspectionMode\nimport androidx.compose.ui.test.DeviceConfigurationOverride\nimport androidx.compose.ui.test.FontScale\nimport androidx.compose.ui.test.junit4.createAndroidComposeRule\nimport androidx.compose.ui.test.onRoot\nimport com.github.takahirom.roborazzi.captureRoboImage\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTab\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTabRow\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\nimport com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions\nimport com.google.samples.apps.nowinandroid.core.testing.util.captureMultiTheme\nimport dagger.hilt.android.testing.HiltTestApplication\nimport org.junit.Rule\nimport org.junit.Test\nimport org.junit.runner.RunWith\nimport org.robolectric.RobolectricTestRunner\nimport org.robolectric.annotation.Config\nimport org.robolectric.annotation.GraphicsMode\nimport org.robolectric.annotation.LooperMode\n\n@RunWith(RobolectricTestRunner::class)\n@GraphicsMode(GraphicsMode.Mode.NATIVE)\n@Config(application = HiltTestApplication::class, qualifiers = \"480dpi\")\n@LooperMode(LooperMode.Mode.PAUSED)\nclass TabsScreenshotTests {\n\n    @get:Rule\n    val composeTestRule = createAndroidComposeRule<ComponentActivity>()\n\n    @Test\n    fun tabs_multipleThemes() {\n        composeTestRule.captureMultiTheme(\"Tabs\") {\n            NiaTabsExample()\n        }\n    }\n\n    @Test\n    fun tabs_hugeFont() {\n        composeTestRule.setContent {\n            CompositionLocalProvider(\n                LocalInspectionMode provides true,\n            ) {\n                DeviceConfigurationOverride(\n                    DeviceConfigurationOverride.FontScale(2f),\n                ) {\n                    NiaTheme {\n                        NiaTabsExample(\"Looooong item\")\n                    }\n                }\n            }\n        }\n        composeTestRule.onRoot()\n            .captureRoboImage(\n                \"src/test/screenshots/Tabs/Tabs_fontScale2.png\",\n                roborazziOptions = DefaultRoborazziOptions,\n            )\n    }\n\n    @Composable\n    private fun NiaTabsExample(label: String = \"Topics\") {\n        Surface {\n            val titles = listOf(label, \"People\")\n            NiaTabRow(selectedTabIndex = 0) {\n                titles.forEachIndexed { index, title ->\n                    NiaTab(\n                        selected = index == 0,\n                        onClick = { },\n                        text = { Text(text = title) },\n                    )\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/TagScreenshotTests.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem\n\nimport androidx.activity.ComponentActivity\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.CompositionLocalProvider\nimport androidx.compose.ui.platform.LocalInspectionMode\nimport androidx.compose.ui.test.DeviceConfigurationOverride\nimport androidx.compose.ui.test.FontScale\nimport androidx.compose.ui.test.junit4.createAndroidComposeRule\nimport androidx.compose.ui.test.onRoot\nimport com.github.takahirom.roborazzi.captureRoboImage\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopicTag\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\nimport com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions\nimport com.google.samples.apps.nowinandroid.core.testing.util.captureMultiTheme\nimport dagger.hilt.android.testing.HiltTestApplication\nimport org.junit.Rule\nimport org.junit.Test\nimport org.junit.runner.RunWith\nimport org.robolectric.RobolectricTestRunner\nimport org.robolectric.annotation.Config\nimport org.robolectric.annotation.GraphicsMode\nimport org.robolectric.annotation.LooperMode\n\n@RunWith(RobolectricTestRunner::class)\n@GraphicsMode(GraphicsMode.Mode.NATIVE)\n@Config(application = HiltTestApplication::class, qualifiers = \"480dpi\")\n@LooperMode(LooperMode.Mode.PAUSED)\nclass TagScreenshotTests {\n\n    @get:Rule\n    val composeTestRule = createAndroidComposeRule<ComponentActivity>()\n\n    @Test\n    fun Tag_multipleThemes() {\n        composeTestRule.captureMultiTheme(\"Tag\") {\n            NiaTopicTag(followed = true, onClick = {}) {\n                Text(\"TOPIC\")\n            }\n        }\n    }\n\n    @Test\n    fun tag_hugeFont() {\n        composeTestRule.setContent {\n            CompositionLocalProvider(\n                LocalInspectionMode provides true,\n            ) {\n                DeviceConfigurationOverride(\n                    DeviceConfigurationOverride.Companion.FontScale(2f),\n                ) {\n                    NiaTheme {\n                        NiaTopicTag(followed = true, onClick = {}) {\n                            Text(\"LOOOOONG TOPIC\")\n                        }\n                    }\n                }\n            }\n        }\n        composeTestRule.onRoot()\n            .captureRoboImage(\n                \"src/test/screenshots/Tag/Tag_fontScale2.png\",\n                roborazziOptions = DefaultRoborazziOptions,\n            )\n    }\n}\n"
  },
  {
    "path": "core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/ThemeTest.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem\n\nimport android.os.Build.VERSION.SDK_INT\nimport android.os.Build.VERSION_CODES\nimport androidx.compose.material3.ColorScheme\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.dynamicDarkColorScheme\nimport androidx.compose.material3.dynamicLightColorScheme\nimport androidx.compose.material3.surfaceColorAtElevation\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.test.junit4.createComposeRule\nimport androidx.compose.ui.unit.dp\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.BackgroundTheme\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.DarkAndroidBackgroundTheme\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.DarkAndroidColorScheme\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.DarkAndroidGradientColors\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.DarkDefaultColorScheme\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.LightAndroidBackgroundTheme\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.LightAndroidColorScheme\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.LightAndroidGradientColors\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.LightDefaultColorScheme\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalBackgroundTheme\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalTintTheme\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.TintTheme\nimport org.junit.Rule\nimport org.junit.Test\nimport org.junit.runner.RunWith\nimport org.robolectric.RobolectricTestRunner\nimport kotlin.test.assertEquals\n\n/**\n * Tests [NiaTheme] using different combinations of the theme mode parameters:\n * darkTheme, disableDynamicTheming, and androidTheme.\n *\n * It verifies that the various composition locals — [MaterialTheme], [LocalGradientColors] and\n * [LocalBackgroundTheme] — have the expected values for a given theme mode, as specified by the\n * design system.\n */\n@RunWith(RobolectricTestRunner::class)\nclass ThemeTest {\n\n    @get:Rule\n    val composeTestRule = createComposeRule()\n\n    @Test\n    fun darkThemeFalse_dynamicColorFalse_androidThemeFalse() {\n        composeTestRule.setContent {\n            NiaTheme(\n                darkTheme = false,\n                disableDynamicTheming = true,\n                androidTheme = false,\n            ) {\n                val colorScheme = LightDefaultColorScheme\n                assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)\n                val gradientColors = defaultGradientColors(colorScheme)\n                assertEquals(gradientColors, LocalGradientColors.current)\n                val backgroundTheme = defaultBackgroundTheme(colorScheme)\n                assertEquals(backgroundTheme, LocalBackgroundTheme.current)\n                val tintTheme = defaultTintTheme()\n                assertEquals(tintTheme, LocalTintTheme.current)\n            }\n        }\n    }\n\n    @Test\n    fun darkThemeTrue_dynamicColorFalse_androidThemeFalse() {\n        composeTestRule.setContent {\n            NiaTheme(\n                darkTheme = true,\n                disableDynamicTheming = true,\n                androidTheme = false,\n            ) {\n                val colorScheme = DarkDefaultColorScheme\n                assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)\n                val gradientColors = defaultGradientColors(colorScheme)\n                assertEquals(gradientColors, LocalGradientColors.current)\n                val backgroundTheme = defaultBackgroundTheme(colorScheme)\n                assertEquals(backgroundTheme, LocalBackgroundTheme.current)\n                val tintTheme = defaultTintTheme()\n                assertEquals(tintTheme, LocalTintTheme.current)\n            }\n        }\n    }\n\n    @Test\n    fun darkThemeFalse_dynamicColorTrue_androidThemeFalse() {\n        composeTestRule.setContent {\n            NiaTheme(\n                darkTheme = false,\n                disableDynamicTheming = false,\n                androidTheme = false,\n            ) {\n                val colorScheme = dynamicLightColorSchemeWithFallback()\n                assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)\n                val gradientColors = dynamicGradientColorsWithFallback(colorScheme)\n                assertEquals(gradientColors, LocalGradientColors.current)\n                val backgroundTheme = defaultBackgroundTheme(colorScheme)\n                assertEquals(backgroundTheme, LocalBackgroundTheme.current)\n                val tintTheme = dynamicTintThemeWithFallback(colorScheme)\n                assertEquals(tintTheme, LocalTintTheme.current)\n            }\n        }\n    }\n\n    @Test\n    fun darkThemeTrue_dynamicColorTrue_androidThemeFalse() {\n        composeTestRule.setContent {\n            NiaTheme(\n                darkTheme = true,\n                disableDynamicTheming = false,\n                androidTheme = false,\n            ) {\n                val colorScheme = dynamicDarkColorSchemeWithFallback()\n                assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)\n                val gradientColors = dynamicGradientColorsWithFallback(colorScheme)\n                assertEquals(gradientColors, LocalGradientColors.current)\n                val backgroundTheme = defaultBackgroundTheme(colorScheme)\n                assertEquals(backgroundTheme, LocalBackgroundTheme.current)\n                val tintTheme = dynamicTintThemeWithFallback(colorScheme)\n                assertEquals(tintTheme, LocalTintTheme.current)\n            }\n        }\n    }\n\n    @Test\n    fun darkThemeFalse_dynamicColorFalse_androidThemeTrue() {\n        composeTestRule.setContent {\n            NiaTheme(\n                darkTheme = false,\n                disableDynamicTheming = true,\n                androidTheme = true,\n            ) {\n                val colorScheme = LightAndroidColorScheme\n                assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)\n                val gradientColors = LightAndroidGradientColors\n                assertEquals(gradientColors, LocalGradientColors.current)\n                val backgroundTheme = LightAndroidBackgroundTheme\n                assertEquals(backgroundTheme, LocalBackgroundTheme.current)\n                val tintTheme = defaultTintTheme()\n                assertEquals(tintTheme, LocalTintTheme.current)\n            }\n        }\n    }\n\n    @Test\n    fun darkThemeTrue_dynamicColorFalse_androidThemeTrue() {\n        composeTestRule.setContent {\n            NiaTheme(\n                darkTheme = true,\n                disableDynamicTheming = true,\n                androidTheme = true,\n            ) {\n                val colorScheme = DarkAndroidColorScheme\n                assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)\n                val gradientColors = DarkAndroidGradientColors\n                assertEquals(gradientColors, LocalGradientColors.current)\n                val backgroundTheme = DarkAndroidBackgroundTheme\n                assertEquals(backgroundTheme, LocalBackgroundTheme.current)\n                val tintTheme = defaultTintTheme()\n                assertEquals(tintTheme, LocalTintTheme.current)\n            }\n        }\n    }\n\n    @Test\n    fun darkThemeFalse_dynamicColorTrue_androidThemeTrue() {\n        composeTestRule.setContent {\n            NiaTheme(\n                darkTheme = false,\n                disableDynamicTheming = false,\n                androidTheme = true,\n            ) {\n                val colorScheme = LightAndroidColorScheme\n                assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)\n                val gradientColors = LightAndroidGradientColors\n                assertEquals(gradientColors, LocalGradientColors.current)\n                val backgroundTheme = LightAndroidBackgroundTheme\n                assertEquals(backgroundTheme, LocalBackgroundTheme.current)\n                val tintTheme = defaultTintTheme()\n                assertEquals(tintTheme, LocalTintTheme.current)\n            }\n        }\n    }\n\n    @Test\n    fun darkThemeTrue_dynamicColorTrue_androidThemeTrue() {\n        composeTestRule.setContent {\n            NiaTheme(\n                darkTheme = true,\n                disableDynamicTheming = false,\n                androidTheme = true,\n            ) {\n                val colorScheme = DarkAndroidColorScheme\n                assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)\n                val gradientColors = DarkAndroidGradientColors\n                assertEquals(gradientColors, LocalGradientColors.current)\n                val backgroundTheme = DarkAndroidBackgroundTheme\n                assertEquals(backgroundTheme, LocalBackgroundTheme.current)\n                val tintTheme = defaultTintTheme()\n                assertEquals(tintTheme, LocalTintTheme.current)\n            }\n        }\n    }\n\n    @Composable\n    private fun dynamicLightColorSchemeWithFallback(): ColorScheme = when {\n        SDK_INT >= VERSION_CODES.S -> dynamicLightColorScheme(LocalContext.current)\n        else -> LightDefaultColorScheme\n    }\n\n    @Composable\n    private fun dynamicDarkColorSchemeWithFallback(): ColorScheme = when {\n        SDK_INT >= VERSION_CODES.S -> dynamicDarkColorScheme(LocalContext.current)\n        else -> DarkDefaultColorScheme\n    }\n\n    private fun emptyGradientColors(colorScheme: ColorScheme): GradientColors =\n        GradientColors(container = colorScheme.surfaceColorAtElevation(2.dp))\n\n    private fun defaultGradientColors(colorScheme: ColorScheme): GradientColors = GradientColors(\n        top = colorScheme.inverseOnSurface,\n        bottom = colorScheme.primaryContainer,\n        container = colorScheme.surface,\n    )\n\n    private fun dynamicGradientColorsWithFallback(colorScheme: ColorScheme): GradientColors = when {\n        SDK_INT >= VERSION_CODES.S -> emptyGradientColors(colorScheme)\n        else -> defaultGradientColors(colorScheme)\n    }\n\n    private fun defaultBackgroundTheme(colorScheme: ColorScheme): BackgroundTheme = BackgroundTheme(\n        color = colorScheme.surface,\n        tonalElevation = 2.dp,\n    )\n\n    private fun defaultTintTheme(): TintTheme = TintTheme()\n\n    private fun dynamicTintThemeWithFallback(colorScheme: ColorScheme): TintTheme = when {\n        SDK_INT >= VERSION_CODES.S -> TintTheme(colorScheme.primary)\n        else -> TintTheme()\n    }\n\n    /**\n     * Workaround for the fact that the NiA design system specify all color scheme values.\n     */\n    private fun assertColorSchemesEqual(\n        expectedColorScheme: ColorScheme,\n        actualColorScheme: ColorScheme,\n    ) {\n        assertEquals(expectedColorScheme.primary, actualColorScheme.primary)\n        assertEquals(expectedColorScheme.onPrimary, actualColorScheme.onPrimary)\n        assertEquals(expectedColorScheme.primaryContainer, actualColorScheme.primaryContainer)\n        assertEquals(expectedColorScheme.onPrimaryContainer, actualColorScheme.onPrimaryContainer)\n        assertEquals(expectedColorScheme.secondary, actualColorScheme.secondary)\n        assertEquals(expectedColorScheme.onSecondary, actualColorScheme.onSecondary)\n        assertEquals(expectedColorScheme.secondaryContainer, actualColorScheme.secondaryContainer)\n        assertEquals(\n            expectedColorScheme.onSecondaryContainer,\n            actualColorScheme.onSecondaryContainer,\n        )\n        assertEquals(expectedColorScheme.tertiary, actualColorScheme.tertiary)\n        assertEquals(expectedColorScheme.onTertiary, actualColorScheme.onTertiary)\n        assertEquals(expectedColorScheme.tertiaryContainer, actualColorScheme.tertiaryContainer)\n        assertEquals(expectedColorScheme.onTertiaryContainer, actualColorScheme.onTertiaryContainer)\n        assertEquals(expectedColorScheme.error, actualColorScheme.error)\n        assertEquals(expectedColorScheme.onError, actualColorScheme.onError)\n        assertEquals(expectedColorScheme.errorContainer, actualColorScheme.errorContainer)\n        assertEquals(expectedColorScheme.onErrorContainer, actualColorScheme.onErrorContainer)\n        assertEquals(expectedColorScheme.background, actualColorScheme.background)\n        assertEquals(expectedColorScheme.onBackground, actualColorScheme.onBackground)\n        assertEquals(expectedColorScheme.surface, actualColorScheme.surface)\n        assertEquals(expectedColorScheme.onSurface, actualColorScheme.onSurface)\n        assertEquals(expectedColorScheme.surfaceVariant, actualColorScheme.surfaceVariant)\n        assertEquals(expectedColorScheme.onSurfaceVariant, actualColorScheme.onSurfaceVariant)\n        assertEquals(expectedColorScheme.inverseSurface, actualColorScheme.inverseSurface)\n        assertEquals(expectedColorScheme.inverseOnSurface, actualColorScheme.inverseOnSurface)\n        assertEquals(expectedColorScheme.outline, actualColorScheme.outline)\n    }\n}\n"
  },
  {
    "path": "core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/TopAppBarScreenshotTests.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.designsystem\n\nimport androidx.activity.ComponentActivity\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.CompositionLocalProvider\nimport androidx.compose.ui.platform.LocalInspectionMode\nimport androidx.compose.ui.test.DeviceConfigurationOverride\nimport androidx.compose.ui.test.FontScale\nimport androidx.compose.ui.test.junit4.createAndroidComposeRule\nimport androidx.compose.ui.test.onRoot\nimport com.github.takahirom.roborazzi.captureRoboImage\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAppBar\nimport com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\nimport com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions\nimport com.google.samples.apps.nowinandroid.core.testing.util.captureMultiTheme\nimport dagger.hilt.android.testing.HiltTestApplication\nimport org.junit.Rule\nimport org.junit.Test\nimport org.junit.runner.RunWith\nimport org.robolectric.RobolectricTestRunner\nimport org.robolectric.annotation.Config\nimport org.robolectric.annotation.GraphicsMode\nimport org.robolectric.annotation.LooperMode\n\n@OptIn(ExperimentalMaterial3Api::class)\n@RunWith(RobolectricTestRunner::class)\n@GraphicsMode(GraphicsMode.Mode.NATIVE)\n@Config(application = HiltTestApplication::class, qualifiers = \"480dpi\")\n@LooperMode(LooperMode.Mode.PAUSED)\nclass TopAppBarScreenshotTests {\n\n    @get:Rule\n    val composeTestRule = createAndroidComposeRule<ComponentActivity>()\n\n    @Test\n    fun topAppBar_multipleThemes() {\n        composeTestRule.captureMultiTheme(\"TopAppBar\") {\n            NiaTopAppBarExample()\n        }\n    }\n\n    @Test\n    fun topAppBar_hugeFont() {\n        composeTestRule.setContent {\n            CompositionLocalProvider(\n                LocalInspectionMode provides true,\n            ) {\n                DeviceConfigurationOverride(\n                    DeviceConfigurationOverride.FontScale(2f),\n                ) {\n                    NiaTheme {\n                        NiaTopAppBarExample()\n                    }\n                }\n            }\n        }\n        composeTestRule.onRoot()\n            .captureRoboImage(\n                \"src/test/screenshots/TopAppBar/TopAppBar_fontScale2.png\",\n                roborazziOptions = DefaultRoborazziOptions,\n            )\n    }\n\n    @Composable\n    private fun NiaTopAppBarExample() {\n        NiaTopAppBar(\n            titleRes = android.R.string.untitled,\n            navigationIcon = NiaIcons.Search,\n            navigationIconContentDescription = \"Navigation icon\",\n            actionIcon = NiaIcons.MoreVert,\n            actionIconContentDescription = \"Action icon\",\n        )\n    }\n}\n"
  },
  {
    "path": "core/designsystem/src/test/resources/robolectric.properties",
    "content": "#\n# Copyright 2025 The Android Open Source Project\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     https://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\nsdk = 35"
  },
  {
    "path": "core/domain/.gitignore",
    "content": "/build"
  },
  {
    "path": "core/domain/README.md",
    "content": "# `:core:domain`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :core\n    direction TB\n    :core:analytics[analytics]:::android-library\n    :core:common[common]:::jvm-library\n    :core:data[data]:::android-library\n    :core:database[database]:::android-library\n    :core:datastore[datastore]:::android-library\n    :core:datastore-proto[datastore-proto]:::jvm-library\n    :core:domain[domain]:::android-library\n    :core:model[model]:::jvm-library\n    :core:network[network]:::android-library\n    :core:notifications[notifications]:::android-library\n  end\n\n  :core:data -.-> :core:analytics\n  :core:data --> :core:common\n  :core:data --> :core:database\n  :core:data --> :core:datastore\n  :core:data --> :core:network\n  :core:data -.-> :core:notifications\n  :core:database --> :core:model\n  :core:datastore -.-> :core:common\n  :core:datastore --> :core:datastore-proto\n  :core:datastore --> :core:model\n  :core:domain --> :core:data\n  :core:domain --> :core:model\n  :core:network --> :core:common\n  :core:network --> :core:model\n  :core:notifications -.-> :core:common\n  :core:notifications --> :core:model\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "core/domain/build.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nplugins {\n    alias(libs.plugins.nowinandroid.android.library)\n    alias(libs.plugins.nowinandroid.android.library.jacoco)\n    id(\"com.google.devtools.ksp\")\n}\n\nandroid {\n    namespace = \"com.google.samples.apps.nowinandroid.core.domain\"\n}\n\ndependencies {\n    api(projects.core.data)\n    api(projects.core.model)\n\n    implementation(libs.javax.inject)\n\n    testImplementation(projects.core.testing)\n}\n"
  },
  {
    "path": "core/domain/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<manifest />\n"
  },
  {
    "path": "core/domain/src/main/kotlin/com/google/samples/apps/nowinandroid/core/domain/GetFollowableTopicsUseCase.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.domain\n\nimport com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository\nimport com.google.samples.apps.nowinandroid.core.domain.TopicSortField.NAME\nimport com.google.samples.apps.nowinandroid.core.domain.TopicSortField.NONE\nimport com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.combine\nimport javax.inject.Inject\n\n/**\n * A use case which obtains a list of topics with their followed state.\n */\nclass GetFollowableTopicsUseCase @Inject constructor(\n    private val topicsRepository: TopicsRepository,\n    private val userDataRepository: UserDataRepository,\n) {\n    /**\n     * Returns a list of topics with their associated followed state.\n     *\n     * @param sortBy - the field used to sort the topics. Default NONE = no sorting.\n     */\n    operator fun invoke(sortBy: TopicSortField = NONE): Flow<List<FollowableTopic>> = combine(\n        userDataRepository.userData,\n        topicsRepository.getTopics(),\n    ) { userData, topics ->\n        val followedTopics = topics\n            .map { topic ->\n                FollowableTopic(\n                    topic = topic,\n                    isFollowed = topic.id in userData.followedTopics,\n                )\n            }\n        when (sortBy) {\n            NAME -> followedTopics.sortedBy { it.topic.name }\n            else -> followedTopics\n        }\n    }\n}\n\nenum class TopicSortField {\n    NONE,\n    NAME,\n}\n"
  },
  {
    "path": "core/domain/src/main/kotlin/com/google/samples/apps/nowinandroid/core/domain/GetRecentSearchQueriesUseCase.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.domain\n\nimport com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery\nimport com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository\nimport kotlinx.coroutines.flow.Flow\nimport javax.inject.Inject\n\n/**\n * A use case which returns the recent search queries.\n */\nclass GetRecentSearchQueriesUseCase @Inject constructor(\n    private val recentSearchRepository: RecentSearchRepository,\n) {\n    operator fun invoke(limit: Int = 10): Flow<List<RecentSearchQuery>> =\n        recentSearchRepository.getRecentSearchQueries(limit)\n}\n"
  },
  {
    "path": "core/domain/src/main/kotlin/com/google/samples/apps/nowinandroid/core/domain/GetSearchContentsUseCase.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.domain\n\nimport com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository\nimport com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic\nimport com.google.samples.apps.nowinandroid.core.model.data.SearchResult\nimport com.google.samples.apps.nowinandroid.core.model.data.UserData\nimport com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource\nimport com.google.samples.apps.nowinandroid.core.model.data.UserSearchResult\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.combine\nimport javax.inject.Inject\n\n/**\n * A use case which returns the searched contents matched with the search query.\n */\nclass GetSearchContentsUseCase @Inject constructor(\n    private val searchContentsRepository: SearchContentsRepository,\n    private val userDataRepository: UserDataRepository,\n) {\n\n    operator fun invoke(\n        searchQuery: String,\n    ): Flow<UserSearchResult> =\n        searchContentsRepository.searchContents(searchQuery)\n            .mapToUserSearchResult(userDataRepository.userData)\n}\n\nprivate fun Flow<SearchResult>.mapToUserSearchResult(userDataStream: Flow<UserData>): Flow<UserSearchResult> =\n    combine(userDataStream) { searchResult, userData ->\n        UserSearchResult(\n            topics = searchResult.topics.map { topic ->\n                FollowableTopic(\n                    topic = topic,\n                    isFollowed = topic.id in userData.followedTopics,\n                )\n            },\n            newsResources = searchResult.newsResources.map { news ->\n                UserNewsResource(\n                    newsResource = news,\n                    userData = userData,\n                )\n            },\n        )\n    }\n"
  },
  {
    "path": "core/domain/src/test/kotlin/com/google/samples/apps/nowinandroid/core/domain/GetFollowableTopicsUseCaseTest.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.domain\n\nimport com.google.samples.apps.nowinandroid.core.domain.TopicSortField.NAME\nimport com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic\nimport com.google.samples.apps.nowinandroid.core.model.data.Topic\nimport com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository\nimport com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository\nimport com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule\nimport kotlinx.coroutines.flow.first\nimport kotlinx.coroutines.test.runTest\nimport org.junit.Rule\nimport org.junit.Test\nimport kotlin.test.assertEquals\n\nclass GetFollowableTopicsUseCaseTest {\n\n    @get:Rule\n    val mainDispatcherRule = MainDispatcherRule()\n\n    private val topicsRepository = TestTopicsRepository()\n    private val userDataRepository = TestUserDataRepository()\n\n    val useCase = GetFollowableTopicsUseCase(\n        topicsRepository,\n        userDataRepository,\n    )\n\n    @Test\n    fun whenNoParams_followableTopicsAreReturnedWithNoSorting() = runTest {\n        // Obtain a stream of followable topics.\n        val followableTopics = useCase()\n\n        // Send some test topics and their followed state.\n        topicsRepository.sendTopics(testTopics)\n        userDataRepository.setFollowedTopicIds(setOf(testTopics[0].id, testTopics[2].id))\n\n        // Check that the order hasn't changed and that the correct topics are marked as followed.\n        assertEquals(\n            listOf(\n                FollowableTopic(testTopics[0], true),\n                FollowableTopic(testTopics[1], false),\n                FollowableTopic(testTopics[2], true),\n            ),\n            followableTopics.first(),\n        )\n    }\n\n    @Test\n    fun whenSortOrderIsByName_topicsSortedByNameAreReturned() = runTest {\n        // Obtain a stream of followable topics, sorted by name.\n        val followableTopics = useCase(\n            sortBy = NAME,\n        )\n\n        // Send some test topics and their followed state.\n        topicsRepository.sendTopics(testTopics)\n        userDataRepository.setFollowedTopicIds(setOf())\n\n        // Check that the followable topics are sorted by the topic name.\n        assertEquals(\n            followableTopics.first(),\n            testTopics\n                .sortedBy { it.name }\n                .map {\n                    FollowableTopic(it, false)\n                },\n        )\n    }\n}\n\nprivate val testTopics = listOf(\n    Topic(\"1\", \"Headlines\", \"\", \"\", \"\", \"\"),\n    Topic(\"2\", \"Android Studio\", \"\", \"\", \"\", \"\"),\n    Topic(\"3\", \"Compose\", \"\", \"\", \"\", \"\"),\n)\n"
  },
  {
    "path": "core/model/.gitignore",
    "content": "/build"
  },
  {
    "path": "core/model/README.md",
    "content": "# `:core:model`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :core\n    direction TB\n    :core:model[model]:::jvm-library\n  end\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "core/model/build.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nplugins {\n    alias(libs.plugins.nowinandroid.jvm.library)\n}\n\ndependencies {\n    api(libs.kotlinx.datetime)\n}\n"
  },
  {
    "path": "core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/DarkThemeConfig.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.model.data\n\nenum class DarkThemeConfig {\n    FOLLOW_SYSTEM,\n    LIGHT,\n    DARK,\n}\n"
  },
  {
    "path": "core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/FollowableTopic.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.model.data\n\n/**\n * A [topic] with the additional information for whether or not it is followed.\n */\n// TODO consider changing to UserTopic and flattening\ndata class FollowableTopic(\n    val topic: Topic,\n    val isFollowed: Boolean,\n)\n"
  },
  {
    "path": "core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/NewsResource.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.model.data\n\nimport kotlinx.datetime.Instant\n\n/**\n * External data layer representation of a fully populated NiA news resource\n */\ndata class NewsResource(\n    val id: String,\n    val title: String,\n    val content: String,\n    val url: String,\n    val headerImageUrl: String?,\n    val publishDate: Instant,\n    val type: String,\n    val topics: List<Topic>,\n)\n"
  },
  {
    "path": "core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/SearchResult.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.model.data\n\n/** An entity that holds the search result */\ndata class SearchResult(\n    val topics: List<Topic> = emptyList(),\n    val newsResources: List<NewsResource> = emptyList(),\n)\n"
  },
  {
    "path": "core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/ThemeBrand.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.model.data\n\nenum class ThemeBrand {\n    DEFAULT,\n    ANDROID,\n}\n"
  },
  {
    "path": "core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/Topic.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.model.data\n\n/**\n * External data layer representation of a NiA Topic\n */\ndata class Topic(\n    val id: String,\n    val name: String,\n    val shortDescription: String,\n    val longDescription: String,\n    val url: String,\n    val imageUrl: String,\n)\n"
  },
  {
    "path": "core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/UserData.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.model.data\n\n/**\n * Class summarizing user interest data\n */\ndata class UserData(\n    val bookmarkedNewsResources: Set<String>,\n    val viewedNewsResources: Set<String>,\n    val followedTopics: Set<String>,\n    val themeBrand: ThemeBrand,\n    val darkThemeConfig: DarkThemeConfig,\n    val useDynamicColor: Boolean,\n    val shouldHideOnboarding: Boolean,\n)\n"
  },
  {
    "path": "core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/UserNewsResource.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.model.data\n\nimport kotlinx.datetime.Instant\n\n/**\n * A [NewsResource] with additional user information such as whether the user is following the\n * news resource's topics and whether they have saved (bookmarked) this news resource.\n */\ndata class UserNewsResource internal constructor(\n    val id: String,\n    val title: String,\n    val content: String,\n    val url: String,\n    val headerImageUrl: String?,\n    val publishDate: Instant,\n    val type: String,\n    val followableTopics: List<FollowableTopic>,\n    val isSaved: Boolean,\n    val hasBeenViewed: Boolean,\n) {\n    constructor(newsResource: NewsResource, userData: UserData) : this(\n        id = newsResource.id,\n        title = newsResource.title,\n        content = newsResource.content,\n        url = newsResource.url,\n        headerImageUrl = newsResource.headerImageUrl,\n        publishDate = newsResource.publishDate,\n        type = newsResource.type,\n        followableTopics = newsResource.topics.map { topic ->\n            FollowableTopic(\n                topic = topic,\n                isFollowed = topic.id in userData.followedTopics,\n            )\n        },\n        isSaved = newsResource.id in userData.bookmarkedNewsResources,\n        hasBeenViewed = newsResource.id in userData.viewedNewsResources,\n    )\n}\n\nfun List<NewsResource>.mapToUserNewsResources(userData: UserData): List<UserNewsResource> =\n    map { UserNewsResource(it, userData) }\n"
  },
  {
    "path": "core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/UserSearchResult.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.model.data\n\n/**\n * An entity of [SearchResult] with additional user information such as whether the user is\n * following a topic.\n */\ndata class UserSearchResult(\n    val topics: List<FollowableTopic> = emptyList(),\n    val newsResources: List<UserNewsResource> = emptyList(),\n)\n"
  },
  {
    "path": "core/navigation/.gitignore",
    "content": "/build"
  },
  {
    "path": "core/navigation/README.md",
    "content": "# `:core:navigation`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :core\n    direction TB\n    :core:navigation[navigation]:::android-library\n  end\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "core/navigation/build.gradle.kts",
    "content": "/*\n * Copyright 2025 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nplugins {\n    alias(libs.plugins.nowinandroid.android.library)\n    alias(libs.plugins.nowinandroid.hilt)\n    alias(libs.plugins.hilt)\n    alias(libs.plugins.kotlin.serialization)\n    alias(libs.plugins.compose)\n}\n\nandroid {\n    namespace = \"com.google.samples.apps.nowinandroid.core.navigation\"\n}\n\ndependencies {\n    api(libs.androidx.navigation3.runtime)\n    implementation(libs.androidx.savedstate.compose)\n    implementation(libs.androidx.lifecycle.viewModel.navigation3)\n\n    testImplementation(libs.truth)\n\n    androidTestImplementation(libs.androidx.compose.ui.test.junit4)\n    androidTestImplementation(libs.androidx.test.ext)\n    androidTestImplementation(libs.androidx.compose.ui.testManifest)\n    androidTestImplementation(libs.androidx.lifecycle.viewModel.testing)\n    androidTestImplementation(libs.truth)\n}\n"
  },
  {
    "path": "core/navigation/src/main/kotlin/com/google/samples/apps/nowinandroid/core/navigation/NavigationState.kt",
    "content": "/*\n * Copyright 2025 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.navigation\n\nimport androidx.annotation.VisibleForTesting\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.derivedStateOf\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.snapshots.SnapshotStateList\nimport androidx.compose.runtime.toMutableStateList\nimport androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator\nimport androidx.navigation3.runtime.NavBackStack\nimport androidx.navigation3.runtime.NavEntry\nimport androidx.navigation3.runtime.NavKey\nimport androidx.navigation3.runtime.rememberDecoratedNavEntries\nimport androidx.navigation3.runtime.rememberNavBackStack\nimport androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator\n\n/**\n * Create a navigation state that persists config changes and process death.\n */\n@Composable\nfun rememberNavigationState(\n    startKey: NavKey,\n    topLevelKeys: Set<NavKey>,\n): NavigationState {\n    val topLevelStack = rememberNavBackStack(startKey)\n    val subStacks = topLevelKeys.associateWith { key -> rememberNavBackStack(key) }\n\n    return remember(startKey, topLevelKeys) {\n        NavigationState(\n            startKey = startKey,\n            topLevelStack = topLevelStack,\n            subStacks = subStacks,\n        )\n    }\n}\n\n/**\n * State holder for navigation state.\n *\n * @param startKey - the starting navigation key. The user will exit the app through this key.\n * @param topLevelStack - the top level back stack. It holds only top level keys.\n * @param subStacks - the back stacks for each top level key\n */\nclass NavigationState(\n    val startKey: NavKey,\n    val topLevelStack: NavBackStack<NavKey>,\n    val subStacks: Map<NavKey, NavBackStack<NavKey>>,\n) {\n    val currentTopLevelKey: NavKey by derivedStateOf { topLevelStack.last() }\n\n    val topLevelKeys\n        get() = subStacks.keys\n\n    @get:VisibleForTesting\n    val currentSubStack: NavBackStack<NavKey>\n        get() = subStacks[currentTopLevelKey]\n            ?: error(\"Sub stack for $currentTopLevelKey does not exist\")\n\n    @get:VisibleForTesting\n    val currentKey: NavKey by derivedStateOf { currentSubStack.last() }\n}\n\n/**\n * Convert NavigationState into NavEntries.\n */\n@Composable\nfun NavigationState.toEntries(\n    entryProvider: (NavKey) -> NavEntry<NavKey>,\n): SnapshotStateList<NavEntry<NavKey>> {\n    val decoratedEntries = subStacks.mapValues { (_, stack) ->\n        val decorators = listOf(\n            rememberSaveableStateHolderNavEntryDecorator<NavKey>(),\n            rememberViewModelStoreNavEntryDecorator<NavKey>(),\n        )\n        rememberDecoratedNavEntries(\n            backStack = stack,\n            entryDecorators = decorators,\n            entryProvider = entryProvider,\n        )\n    }\n\n    return topLevelStack\n        .flatMap { decoratedEntries[it] ?: emptyList() }\n        .toMutableStateList()\n}\n"
  },
  {
    "path": "core/navigation/src/main/kotlin/com/google/samples/apps/nowinandroid/core/navigation/Navigator.kt",
    "content": "/*\n * Copyright 2025 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.navigation\n\nimport androidx.navigation3.runtime.NavKey\n\n/**\n * Handles navigation events (forward and back) by updating the navigation state.\n *\n * @param state - The navigation state that will be updated in response to navigation events.\n */\nclass Navigator(val state: NavigationState) {\n\n    /**\n     * Navigate to a navigation key\n     *\n     * @param key - the navigation key to navigate to.\n     */\n    fun navigate(key: NavKey) {\n        when (key) {\n            state.currentTopLevelKey -> clearSubStack()\n            in state.topLevelKeys -> goToTopLevel(key)\n            else -> goToKey(key)\n        }\n    }\n\n    /**\n     * Go back to the previous navigation key.\n     */\n    fun goBack() {\n        when (state.currentKey) {\n            state.startKey -> error(\"You cannot go back from the start route\")\n            state.currentTopLevelKey -> {\n                // We're at the base of the current sub stack, go back to the previous top level\n                // stack.\n                state.topLevelStack.removeLastOrNull()\n            }\n            else -> state.currentSubStack.removeLastOrNull()\n        }\n    }\n\n    /**\n     * Go to a non top level key.\n     */\n    private fun goToKey(key: NavKey) {\n        state.currentSubStack.apply {\n            // Remove it if it's already in the stack so it's added at the end.\n            remove(key)\n            add(key)\n        }\n    }\n\n    /**\n     * Go to a top level stack.\n     */\n    private fun goToTopLevel(key: NavKey) {\n        state.topLevelStack.apply {\n            if (key == state.startKey) {\n                // This is the start key. Clear the stack so it's added as the only key.\n                clear()\n            } else {\n                // Remove it if it's already in the stack so it's added at the end.\n                remove(key)\n            }\n            add(key)\n        }\n    }\n\n    /**\n     * Clearing all but the root key in the current sub stack.\n     */\n    private fun clearSubStack() {\n        state.currentSubStack.run {\n            if (size > 1) subList(1, size).clear()\n        }\n    }\n}\n"
  },
  {
    "path": "core/navigation/src/test/kotlin/com/google/samples/apps/nowinandroid/core/navigation/NavigatorTest.kt",
    "content": "/*\n * Copyright 2025 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.navigation\n\nimport androidx.navigation3.runtime.NavBackStack\nimport androidx.navigation3.runtime.NavKey\nimport com.google.common.truth.Truth.assertThat\nimport org.junit.Before\nimport org.junit.Test\nimport kotlin.test.assertFailsWith\n\nprivate object TestFirstTopLevelKey : NavKey\nprivate object TestSecondTopLevelKey : NavKey\nprivate object TestThirdTopLevelKey : NavKey\nprivate object TestKeyFirst : NavKey\nprivate object TestKeySecond : NavKey\n\nclass NavigatorTest {\n\n    private lateinit var navigationState: NavigationState\n    private lateinit var navigator: Navigator\n\n    @Before\n    fun setup() {\n        val startKey = TestFirstTopLevelKey\n        val topLevelStack = NavBackStack<NavKey>(startKey)\n        val topLevelKeys = listOf(\n            startKey,\n            TestSecondTopLevelKey,\n            TestThirdTopLevelKey,\n        )\n        val subStacks = topLevelKeys.associateWith { key -> NavBackStack(key) }\n\n        navigationState = NavigationState(\n            startKey = startKey,\n            topLevelStack = topLevelStack,\n            subStacks = subStacks,\n        )\n        navigator = Navigator(navigationState)\n    }\n\n    @Test\n    fun testStartKey() {\n        assertThat(navigationState.startKey).isEqualTo(TestFirstTopLevelKey)\n        assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)\n    }\n\n    @Test\n    fun testNavigate() {\n        navigator.navigate(TestKeyFirst)\n\n        assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)\n        assertThat(navigationState.subStacks[TestFirstTopLevelKey]?.last()).isEqualTo(TestKeyFirst)\n    }\n\n    @Test\n    fun testNavigateTopLevel() {\n        navigator.navigate(TestSecondTopLevelKey)\n        assertThat(navigationState.currentTopLevelKey).isEqualTo(TestSecondTopLevelKey)\n    }\n\n    @Test\n    fun testNavigateSingleTop() {\n        navigator.navigate(TestKeyFirst)\n\n        assertThat(navigationState.currentSubStack).containsExactly(\n            TestFirstTopLevelKey,\n            TestKeyFirst,\n        ).inOrder()\n\n        navigator.navigate(TestKeyFirst)\n\n        assertThat(navigationState.currentSubStack).containsExactly(\n            TestFirstTopLevelKey,\n            TestKeyFirst,\n        ).inOrder()\n    }\n\n    @Test\n    fun testNavigateTopLevelSingleTop() {\n        navigator.navigate(TestSecondTopLevelKey)\n        navigator.navigate(TestKeyFirst)\n\n        assertThat(navigationState.currentSubStack).containsExactly(\n            TestSecondTopLevelKey,\n            TestKeyFirst,\n        ).inOrder()\n\n        navigator.navigate(TestSecondTopLevelKey)\n\n        assertThat(navigationState.currentSubStack).containsExactly(\n            TestSecondTopLevelKey,\n        ).inOrder()\n    }\n\n    @Test\n    fun testSubStack() {\n        navigator.navigate(TestKeyFirst)\n\n        assertThat(navigationState.currentKey).isEqualTo(TestKeyFirst)\n        assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)\n\n        navigator.navigate(TestKeySecond)\n\n        assertThat(navigationState.currentKey).isEqualTo(TestKeySecond)\n        assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)\n    }\n\n    @Test\n    fun testMultiStack() {\n        // add to start stack\n        navigator.navigate(TestKeyFirst)\n\n        assertThat(navigationState.currentKey).isEqualTo(TestKeyFirst)\n        assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)\n\n        // navigate to new top level\n        navigator.navigate(TestSecondTopLevelKey)\n\n        assertThat(navigationState.currentKey).isEqualTo(TestSecondTopLevelKey)\n        assertThat(navigationState.currentTopLevelKey).isEqualTo(TestSecondTopLevelKey)\n\n        // add to new stack\n        navigator.navigate(TestKeySecond)\n\n        assertThat(navigationState.currentKey).isEqualTo(TestKeySecond)\n        assertThat(navigationState.currentTopLevelKey).isEqualTo(TestSecondTopLevelKey)\n\n        // go back to start stack\n        navigator.navigate(TestFirstTopLevelKey)\n\n        assertThat(navigationState.currentKey).isEqualTo(TestKeyFirst)\n        assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)\n    }\n\n    @Test\n    fun testPopOneNonTopLevel() {\n        navigator.navigate(TestKeyFirst)\n        navigator.navigate(TestKeySecond)\n\n        assertThat(navigationState.currentSubStack).containsExactly(\n            TestFirstTopLevelKey,\n            TestKeyFirst,\n            TestKeySecond,\n        ).inOrder()\n\n        navigator.goBack()\n\n        assertThat(navigationState.currentSubStack).containsExactly(\n            TestFirstTopLevelKey,\n            TestKeyFirst,\n        ).inOrder()\n\n        assertThat(navigationState.currentKey).isEqualTo(TestKeyFirst)\n        assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)\n    }\n\n    @Test\n    fun testPopOneTopLevel() {\n        navigator.navigate(TestKeyFirst)\n        navigator.navigate(TestSecondTopLevelKey)\n\n        assertThat(navigationState.currentSubStack).containsExactly(\n            TestSecondTopLevelKey,\n        ).inOrder()\n\n        assertThat(navigationState.currentKey).isEqualTo(TestSecondTopLevelKey)\n        assertThat(navigationState.currentTopLevelKey).isEqualTo(TestSecondTopLevelKey)\n\n        // remove TopLevel\n        navigator.goBack()\n\n        assertThat(navigationState.currentSubStack).containsExactly(\n            TestFirstTopLevelKey,\n            TestKeyFirst,\n        ).inOrder()\n\n        assertThat(navigationState.currentKey).isEqualTo(TestKeyFirst)\n        assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)\n    }\n\n    @Test\n    fun popMultipleNonTopLevel() {\n        navigator.navigate(TestKeyFirst)\n        navigator.navigate(TestKeySecond)\n\n        assertThat(navigationState.currentSubStack).containsExactly(\n            TestFirstTopLevelKey,\n            TestKeyFirst,\n            TestKeySecond,\n        ).inOrder()\n\n        navigator.goBack()\n        navigator.goBack()\n\n        assertThat(navigationState.currentSubStack).containsExactly(\n            TestFirstTopLevelKey,\n        ).inOrder()\n\n        assertThat(navigationState.currentKey).isEqualTo(TestFirstTopLevelKey)\n        assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)\n    }\n\n    @Test\n    fun popMultipleTopLevel() {\n        // second sub-stack\n        navigator.navigate(TestSecondTopLevelKey)\n        navigator.navigate(TestKeyFirst)\n\n        assertThat(navigationState.currentSubStack).containsExactly(\n            TestSecondTopLevelKey,\n            TestKeyFirst,\n        ).inOrder()\n\n        // third sub-stack\n        navigator.navigate(TestThirdTopLevelKey)\n        navigator.navigate(TestKeySecond)\n\n        assertThat(navigationState.currentSubStack).containsExactly(\n            TestThirdTopLevelKey,\n            TestKeySecond,\n        ).inOrder()\n\n        repeat(4) {\n            navigator.goBack()\n        }\n\n        assertThat(navigationState.currentSubStack).containsExactly(\n            TestFirstTopLevelKey,\n        ).inOrder()\n\n        assertThat(navigationState.currentKey).isEqualTo(TestFirstTopLevelKey)\n        assertThat(navigationState.currentTopLevelKey).isEqualTo(TestFirstTopLevelKey)\n    }\n\n    @Test\n    fun throwOnEmptyBackStack() {\n        assertFailsWith<IllegalStateException> {\n            navigator.goBack()\n        }\n    }\n}\n"
  },
  {
    "path": "core/network/.gitignore",
    "content": "/build"
  },
  {
    "path": "core/network/README.md",
    "content": "# `:core:network`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :core\n    direction TB\n    :core:common[common]:::jvm-library\n    :core:model[model]:::jvm-library\n    :core:network[network]:::android-library\n  end\n\n  :core:network --> :core:common\n  :core:network --> :core:model\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "core/network/build.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport com.android.build.api.variant.BuildConfigField\nimport java.io.StringReader\nimport java.util.Properties\n\nplugins {\n    alias(libs.plugins.nowinandroid.android.library)\n    alias(libs.plugins.nowinandroid.android.library.jacoco)\n    alias(libs.plugins.nowinandroid.hilt)\n    id(\"kotlinx-serialization\")\n}\n\nandroid {\n    buildFeatures {\n        buildConfig = true\n    }\n    namespace = \"com.google.samples.apps.nowinandroid.core.network\"\n    testOptions.unitTests.isIncludeAndroidResources = true\n}\n\ndependencies {\n    api(libs.kotlinx.datetime)\n    api(projects.core.common)\n    api(projects.core.model)\n\n    implementation(libs.coil.kt)\n    implementation(libs.coil.kt.svg)\n    implementation(libs.kotlinx.serialization.json)\n    implementation(libs.okhttp.logging)\n    implementation(libs.retrofit.core)\n    implementation(libs.retrofit.kotlin.serialization)\n\n    testImplementation(libs.kotlinx.coroutines.test)\n}\n\nval backendUrl = providers.fileContents(\n    isolated.rootProject.projectDirectory.file(\"local.properties\")\n).asText.map { text ->\n    val properties = Properties()\n    properties.load(StringReader(text))\n    properties[\"BACKEND_URL\"]\n}.orElse(\"http://example.com\")\n\nandroidComponents {\n    onVariants {\n        it.buildConfigFields!!.put(\"BACKEND_URL\", backendUrl.map { value ->\n            BuildConfigField(type = \"String\", value = \"\"\"\"$value\"\"\"\", comment = null)\n        })\n    }\n}\n"
  },
  {
    "path": "core/network/lint.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<lint>\n    <!--\n    Lint crashes when it tries to analyse a file without a package name:\n    java.lang.IllegalStateException: () -> kotlin.String at org.jetbrains.kotlin.asJava.classes.KtLightClassForFacadeImpl$Companion.createForFacadeNoCache\n    -->\n    <issue id=\"LintError\">\n        <ignore path=\"**/JvmUnitTestDemoAssetManager.kt\" />\n    </issue>\n</lint>\n"
  },
  {
    "path": "core/network/src/demo/kotlin/com/google/samples/apps/nowinandroid/core/network/di/FlavoredNetworkModule.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.network.di\n\nimport com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource\nimport com.google.samples.apps.nowinandroid.core.network.demo.DemoNiaNetworkDataSource\nimport dagger.Binds\nimport dagger.Module\nimport dagger.hilt.InstallIn\nimport dagger.hilt.components.SingletonComponent\n\n@Module\n@InstallIn(SingletonComponent::class)\ninternal interface FlavoredNetworkModule {\n\n    @Binds\n    fun binds(impl: DemoNiaNetworkDataSource): NiaNetworkDataSource\n}\n"
  },
  {
    "path": "core/network/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <uses-permission android:name=\"android.permission.INTERNET\" />\n\n</manifest>\n"
  },
  {
    "path": "core/network/src/main/assets/news.json",
    "content": "[\n  {\n    \"id\": \"1\",\n    \"title\": \"Android Dev Summit ’22: Coming to you, online and around the world! ⛰️\",\n    \"content\": \"Android Dev Summit is back, so join us online or in person — for the first time since 2019 — at locations around the world. We’ll be sharing the sessions live on YouTube in three tracks spread across three weeks.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/10/android-dev-summit.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEh1VWQmqQu6wDswls9f_5NpEQnq4eR57g2NwzWvhKItcKtV6rb_Cyo75XSyL6vvmCIo4tzQn-8taNagEp7QG0KU1L4yMqwbYozNMzBMEFxEN2XintAhy5nLI4RQDaOXr8dgiIFdGOBMdl577Ndelzc0tDBzjI6mz7e4MF8_Tn09KWguZi6I-bS5NbJn/w1200-h630-p-k-no-nu/unnamed%20%2816%29.png\",\n    \"publishDate\": \"2022-10-04T23:00:00.000Z\",\n    \"type\": \"Event 📆\",\n    \"topics\": [\n      \"1\"\n    ],\n    \"authors\": [\n      \"64\"\n    ]\n  },\n  {\n    \"id\": \"2\",\n    \"title\": \"The new Google Pixel Watch is here  — start building for Wear OS! ⌚\",\n    \"content\": \"We launched the Google Pixel Watch, powered by Wear OS 3.5, at the Made by Google event — the perfect device to showcase apps built with Compose for Wear OS. With Compose for Wear OS, the Tiles Material library, and the tools in Android Studio Dolphin, it’s now simpler and more efficient than ever to make apps for WearOS.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/10/the-new-google-pixel-watch-is-here.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhH63icac2kmydOI8Fs2I09KiuRA3GUo2pfZ1Wpf0M5JIEoVQ8dj9LYSl8jpxSQlmlsUVXoeXbwN4UbHMCf5p0M7FHh_EXzMeFRAJ-6feI9-7eIyhBmtGZSD5o-MItwFLH_ESi15Cxd01AlznWaGy9WDqhK0NWtMQwiWELg3xE1I7hba-_7eVqs747V/w1200-h630-p-k-no-nu/WhasNewinPixelDevices_Social.png\",\n    \"publishDate\": \"2022-10-06T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"1\",\n      \"3\",\n      \"19\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"3\",\n    \"title\": \"Listen to our major Text to Speech upgrades for 64 bit devices 💬\",\n    \"content\": \"An upgrade to Speech Services by Google is rolling out to 64-bit Android devices via Google Play over the next few weeks, providing clearer, more natural voices. All 421 voices in 67 languages are being upgraded with a new voice model and synthesizer. The post includes more on this update, including demonstrations of some voice upgrades, along with guidance on how to use text to speech in your projects.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/09/listen-to-our-major-text-to-speech-upgrades-for-64-bit-devices.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjrHro6d3BTw7ZZ4IXgfb6_8aESB7-SsWfelDSSInZVamiMSnYpBZzGBaZBBrWxWwYgLqOHuOtroGvGjxrwzdUkhjwuIvM1u6chIblGKS1gQ6JVkjXr-Vztyk2zoYb1ylvhNgLgC5q6M-7LaiXT1xnAT96DvkPx89APNb8JEaz-1mnMRcfaOYYBHzQL/w1200-h630-p-k-no-nu/Text%20to%20Speech%20-%20Social%20-%201024x512.png\",\n    \"publishDate\": \"2022-09-27T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"14\"\n    ],\n    \"authors\": [\n      \"66\",\n      \"67\"\n    ]\n  },\n  {\n    \"id\": \"4\",\n    \"title\": \"MAD Skills Compose: Powerful Toolkit\",\n    \"content\": \"Learn about the powerful toolkit of UI components that ship with Compose enabling you to build rich UIs and interactions.\",\n    \"url\": \"https://medium.com/androiddevelopers/compose-toolkit-8d3651228764\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1200/1*3FZeNmAPZDYUCmgL0cBXoA.png\",\n    \"publishDate\": \"2022-09-29T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"3\"\n    ],\n    \"authors\": [\n      \"68\"\n    ]\n  },\n  {\n    \"id\": \"5\",\n    \"title\": \"MAD Skills Compose: Accelerate development with tooling\",\n    \"content\": \"Learn how to accelerate your Compose development process with a dive into Android Studio tooling support, including live templates, gutter icons for drawables and colors, composable preview functions, multipreview, preview on device, live edits of literals, and the Layout Inspector.\",\n    \"url\": \"https://medium.com/androiddevelopers/compose-tooling-42621bd8719b\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1200/1*WVUBQsNgePqQxmrHjaID4g.png\",\n    \"publishDate\": \"2022-10-06T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"3\"\n    ],\n    \"authors\": [\n      \"68\"\n    ]\n  },\n  {\n    \"id\": \"6\",\n    \"title\": \"Deep Links Crash Course: Part 3 - Troubleshooting\",\n    \"content\": \"In part 3 of the Deep Links Crash Course you'll learn how to diagnose and debug common issues with deep links using command line tools and the Android Debug Bridge (ADB).\",\n    \"url\": \"https://www.youtube.com/watch?v=OAxJ2kWG4ZI\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/OAxJ2kWG4ZI/maxresdefault.jpg\",\n    \"publishDate\": \"2022-09-29T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"2\",\n      \"5\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"7\",\n    \"title\": \"Deep Links Crash Course: Part 4 - Deep links for your business\",\n    \"content\": \"Part 4 of the Deep Links Crash Course explores Deep links for your business, covering the importance and benefits of implementing deep links for your app, your users, and your business, including success stories and how App Links provide an optimal experience for users.\",\n    \"url\": \"https://www.youtube.com/watch?v=UvMIswgsJF8\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/UvMIswgsJF8/maxresdefault.jpg\",\n    \"publishDate\": \"2022-10-05T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"2\",\n      \"5\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"8\",\n    \"title\": \"Migrating the AOSP QuickSearchBox App to Kotlin\",\n    \"content\": \"This article includes the methodology used in the migration to Kotlin, how manual changes were addressed, and what the impact to APK size and build speed was.\",\n    \"url\": \"https://medium.com/androiddevelopers/migrating-the-aosp-quicksearchbox-app-to-kotlin-1264346619ec\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/720/1*cWnPe-kD4hAVuH3IIcNUcA.png\",\n    \"publishDate\": \"2022-09-22T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"10\"\n    ],\n    \"authors\": [\n      \"69\"\n    ]\n  },\n  {\n    \"id\": \"9\",\n    \"title\": \"Prepare your Android Project for Android Gradle plugin 8.0 API changes\",\n    \"content\": \"How to prepare your Android Project for Android Gradle plugin 8.0 API changes; this article specifically addresses migrating from the Transform APIs — which slow down builds and will be removed in 8.0 — to the Artifacts API and Instrumentation API.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/10/prepare-your-android-project-for-agp8-changes.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgy74acii7wq-Z2pW8TUSga1YGpRKjLZjroOlZlUillRJuTFIlGpUi93PPletKlkcAaz9u6NgF8_LejO9686VYEWNtO2ypawLgpY2QW7JMtrMSVTlPsRGgEDUiQJKUfzEXw2Q_Y7qX1CSUlH9lma8Jjdm3AqMogbEI6PScD3AK1XsWgHmVeqJlVqUiK/w1200-h630-p-k-no-nu/Header-PrepareyourAndroidProjectforAGP8.0Changes%20.png\",\n    \"publishDate\": \"2022-10-05T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"5\",\n      \"5\"\n    ],\n    \"authors\": [\n      \"70\",\n      \"71\",\n      \"72\"\n    ]\n  },\n  {\n    \"id\": \"10\",\n    \"title\": \"Optimize for Android (Go edition) : Lessons from Google apps Part 2\",\n    \"content\": \"Part two of the Optimize for Android Go : Lessons from Google apps blog series, covering strategies Google apps used to improve startup latency and optimize app size — things that will improve the user experience for any app.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/09/optimize-for-android-go-lessons-from-google-apps-part-2.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiD7uoBIqlA_WYXwuhyDxKy1Nt2ibaa_GYd9l8ewfQcC7f-f11t0WRCTTS6XhwnkJMqWUxSX-nxAq9DD8oBbk_Om2ik0yNMOV8lHw0sGmRAixLY2T0dxpKtQe0DnsVrzmexNSX1-BYdz9p0cvCtdXoxgDi1Mx6OLixzC5JAnxEEAf1TkHrTQON0fURg/w1200-h630-p-k-no-nu/MAD%20App%20Architecture%20launch%20-%20Mobile%20%281%29%20%281%29.png\",\n    \"publishDate\": \"2022-09-27T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"7\"\n    ],\n    \"authors\": [\n      \"73\"\n    ]\n  },\n  {\n    \"id\": \"11\",\n    \"title\": \"Helping users discover apps for all their devices from their phone\",\n    \"content\": \"Changes in Google Play are helping users discover apps for all their devices from their phone, including homepages for non-phone devices, a device search filter, and the ability to remotely install an app to another device.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/09/helping-users-discover-apps-for-all-their-devices-from-their-phone.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjyGaErzRykqFDWOwEmkHWos7vU7OhdETz5GNEjObA7FOhCudnf5DiQ1hAfpxuq102pwxuBf_ZCeifURihNeAwNnLj46pkdoBdbl_JYn8A9plqwaqS8D_0XML6B7Bupt0GhPZuABbfTXB_nkWsVVW6faVQXqpetHIV6QlNQyl1WD6zuojFf-U7wDSHO/w1200-h630-p-k-no-nu/image3.gif\",\n    \"publishDate\": \"2022-09-26T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"12\"\n    ],\n    \"authors\": [\n      \"74\"\n    ]\n  },\n  {\n    \"id\": \"12\",\n    \"title\": \"Deep Links Crash Course : Part 3 Troubleshooting Your Deep Links\",\n    \"content\": \"The Deep Links Crash Course continues with Summers writing an article on troubleshooting deep links. He goes over common issues that can occur with deep links and how to solve them.\",\n    \"url\": \"https://medium.com/androiddevelopers/deep-links-crash-course-part-3-troubleshooting-your-deep-links-61329fecb93\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1200/1*IsRqP0Fe9I6YLxrJybIG6Q.png\",\n    \"publishDate\": \"2022-09-15T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"6\"\n    ],\n    \"authors\": [\n      \"79\"\n    ]\n  },\n  {\n    \"id\": \"13\",\n    \"title\": \"Jetpack Compose Composition Tracing\",\n    \"content\": \"Ben covered Compose Composition Tracing, a new feature that allows showing Jetpack Compose composable functions in the Android Studio Flamingo system trace profiler. This feature gives you the low intrusiveness from system tracing, with method tracing levels of detail in composition. This is great for checking your Compose app’s performance and working out why your app may not be performing as you expect. Learn more about this feature in the post!\",\n    \"url\": \"https://medium.com/androiddevelopers/jetpack-compose-composition-tracing-9ec2b3aea535\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/700/1*jPVPY4GjPRK3prnJ2o09cQ.png\",\n    \"publishDate\": \"2022-09-19T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"3\",\n      \"7\"\n    ],\n    \"authors\": [\n      \"80\"\n    ]\n  },\n  {\n    \"id\": \"14\",\n    \"title\": \"Android Studio: Dolphin is available\",\n    \"content\": \"Android Studio Dolphin is here! In this post, Takeshi goes over the three key themes: Jetpack Compose, Wear OS, and development productivity. Exciting features include the Compose Animation Inspector, the Wear OS Emulator Pairing Assistant, and Gradle managed virtual devices. Learn about all the new features in the blog post or the video!\",\n    \"url\": \"https://www.youtube.com/watch?v=EQ_btxhpRzU&t=1s\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/EQ_btxhpRzU/maxresdefault.jpg\",\n    \"publishDate\": \"2022-09-14T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"5\",\n      \"5\"\n    ],\n    \"authors\": [\n      \"81\"\n    ]\n  },\n  {\n    \"id\": \"15\",\n    \"title\": \"Mad Skills: Compose\",\n    \"content\": \"Chris started a brand new MAD Skills series on Compose. This series is a great place to start to learn how to think and start building apps with Compose.\",\n    \"url\": \"https://www.youtube.com/watch?v=4UXJTeb9Khg&t=1s\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/4UXJTeb9Khg/maxresdefault.jpg\",\n    \"publishDate\": \"2022-09-12T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"3\"\n    ],\n    \"authors\": [\n      \"68\"\n    ]\n  },\n  {\n    \"id\": \"16\",\n    \"title\": \"Introducing Compose Camp\",\n    \"content\": \"We launched Compose Camp, a series of in-person and virtual sessions where you can learn how to build Android apps with Jetpack Compose alongside your peers. Compose Camp has two tracks: the beginner track caters to complete Android beginners including people that have no coding experience, and the experienced track is for Android developers who want to learn how to migrate to Compose and stop using XML.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/09/learn-jetpack-compose-at-compose-camp-near-you.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjn6__UOZ_lipXjY09TrHeXW4HjKodPUdFzmovYRn1tLwdYr8GVKjCN6wfgKpcby5rrJ6JFrUmZozT7aeDkp8x7v46QdH6wtz9ysQFTZAQPwswFfGWQkWcPmKHbeELq_BUUhazt4ppYm9ErYEo7HbFzPCfBid4IQ9qL-hydSgRpJx7w2lNZKh5ylNcb/w1200-h630-p-k-no-nu/Compose%20Camp%203.png\",\n    \"publishDate\": \"2022-09-12T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"1\",\n      \"3\"\n    ],\n    \"authors\": [\n      \"78\"\n    ]\n  },\n  {\n    \"id\": \"17\",\n    \"title\": \"Privacy Sandbox developer preview 5 🔐\",\n    \"content\": \"The Privacy Sandbox aims to develop new technologies that improve user privacy and enable effective, personalized advertising experiences for mobile apps. Developer Preview 5 was released, this version is a major milestone that will become the foundation for upcoming Privacy Sandbox Beta releases. Please keep giving us your feedback! See what’s changed in the blog post.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/09/privacy-sandbox-developer-preview-5-is-here.html\",\n    \"headerImageUrl\": \"https://services.google.com/fb/forms/privacysandbox/fb/forms/getlogo/5679849861677056/\",\n    \"publishDate\": \"2022-09-06T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"11\"\n    ],\n    \"authors\": [\n      \"77\"\n    ]\n  },\n  {\n    \"id\": \"18\",\n    \"title\": \"Guide to app modularization 🧩\",\n    \"content\": \"The team just released new guidance on modularization. Guidance on this topic has been one of the top community requests and here it is! The guide is split into two parts. The overview page gives you a high level, theoretical overview of the matter and its benefits. The common modularization patterns page dives deep into practical examples in the context of modern Android architecture. Take a look at the guide announcement blog post to learn more about this.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/09/announcing-new-guide-to-android-app-modularization.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEj0ZsQN4PZ_SQ975Hfbc-LDFXMTgOr6RVf42kSUBqNxfv9OcDvc6dTYRZPynsYx0JIlXT7k5TMz_Kjq7bJstYb4dUy2ZX6ilugMH20JudIZISLWEsa19f8sN0hDxA7JWXgS570gDxkXNp3ioHxxH42tvquQ0wUK-qPS6Qv2OeGK06HhumP2vvC0V07V/s1600/Android-AppModularization_4209x1253.png\",\n    \"publishDate\": \"2022-09-05T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"4\"\n    ],\n    \"authors\": [\n      \"82\"\n    ]\n  },\n  {\n    \"id\": \"19\",\n    \"title\": \"Build an offline-first app\",\n    \"content\": \"If you need to make your app work offline, we got you covered. The new Build an offline-first app guide helps you design your app to properly handle reads and writes, and deal with synchronization and conflict resolution in a device with no Internet connectivity.\",\n    \"url\": \"https://developer.android.com/topic/architecture/data-layer/offline-first\",\n    \"headerImageUrl\": \"https://developer.android.com/static/images/topic/architecture/data-layer/data-layer.png\",\n    \"publishDate\": \"2022-09-13T23:00:00.000Z\",\n    \"type\": \"Docs 📑\",\n    \"topics\": [\n      \"4\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"20\",\n    \"title\": \"State holders and UI state page \",\n    \"content\": \"Another new guide is the State holders and UI state page in the UI layer docs. Not everything needs to be present in ViewModel classes. This page goes through the different types of state holders you can find in the UI layer and what their responsibilities are.\",\n    \"url\": \"https://developer.android.com/topic/architecture/ui-layer/stateholders\",\n    \"headerImageUrl\": \"https://developer.android.com/static/images/topic/architecture/ui-layer/udf.png\",\n    \"publishDate\": \"2022-09-13T23:00:00.000Z\",\n    \"type\": \"Docs 📑\",\n    \"topics\": [\n      \"4\",\n      \"3\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"21\",\n    \"title\": \"Architecture pathway\",\n    \"content\": \"If you want to learn all about Architecture and be up-to-date with our current best practices, check out the Architecture pathway, that got updated with all the videos of the Architecture MAD Skills series we produced early this year and the new docs.\",\n    \"url\": \"https://developer.android.com/courses/pathways/android-architecture\",\n    \"headerImageUrl\": \"https://developer.android.com/static/topic/libraries/architecture/images/mad-arch-overview.png\",\n    \"publishDate\": \"2022-09-13T23:00:00.000Z\",\n    \"type\": \"Codelab\",\n    \"topics\": [\n      \"4\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"22\",\n    \"title\": \"Mad Skills: Performance ⚡️\",\n    \"content\": \"Ben wrote this blog post that contains a summary of all the videos on MAD Skills: Performance! Don't miss it out!\",\n    \"url\": \"https://medium.com/androiddevelopers/mad-skills-performance-wrap-up-33688abfc51f\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/720/0*qdkZp112bKTGtQIN\",\n    \"publishDate\": \"2022-09-13T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"7\"\n    ],\n    \"authors\": [\n      \"36\"\n    ]\n  },\n  {\n    \"id\": \"23\",\n    \"title\": \"AndroidX releases 🚀\",\n    \"content\": \"Since the previous episode, there are some AndroidX releases worth highlighting.Core and core-ktx made it to 1.9.0 stable. This version improves compatibility with Android 13 adding parity between the accessibility framework and compat APIs, and some other additions. Other releases include new in beta Car app 1.3, and new in alpha Navigation 2.6 and Test Ui Automator 2.3.\",\n    \"url\": \"https://developer.android.com/jetpack/androidx/versions/all-channel\",\n    \"headerImageUrl\": \"https://developer.android.com/images/social/android-developers.png\",\n    \"publishDate\": \"2022-09-13T23:00:00.000Z\",\n    \"type\": \"Jetpack release 🚀\",\n    \"topics\": [\n      \"8\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"24\",\n    \"title\": \"Precise Improvements: How TikTok Enhanced its Video Social Experience on Android\",\n    \"content\": \"The Developer Relations team wrote about how TikTok Enhanced its Video Social Experience on Android. They were able to significantly improve their overall performance by following Android’s performance guidance, and employing their deep understanding of development tools such as Android Gradle Plugin and Jetpack libraries. Read more here!\",\n    \"url\": \"https://android-developers.googleblog.com/2022/08/precise-improvements-how-tiktok-enhanced-its-social-experience-on-android.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhvwJsvRHyECCaiD1FaBBCLGrUr-PoZoDaqm9aUKoswBFPOlWyKNvcC94FhX6M6Ugdo0wVTdZyI48BUmKaiA1xSgOcEE_xOFt9EGmoHd9PDHyJ4mAiKrfjnFHBIEKgjL1JhFeTQWbjWec4DJX-q9lnYAw5b9l0vC7nM09QBKtItv7JmBNxjYosCroQI/s1600/241588700__38488906__148018.png\",\n    \"publishDate\": \"2022-09-13T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"7\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"25\",\n    \"title\": \"The deep links crash course, Part 1: Introduction to deep links\",\n    \"content\": \"Sabs started a crash course series about deep links. The first part is an introduction to deep links with a blog post and a video. Get to know what a deep link is, go from URIs to app links, and more!\",\n    \"url\": \"https://medium.com/androiddevelopers/the-deep-links-crash-course-part-1-introduction-to-deep-links-2189e509e269\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/720/1*m44rS8zc3W23lmDy1_Vu8g.png\",\n    \"publishDate\": \"2022-09-01T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"7\"\n    ],\n    \"authors\": [\n      \"83\"\n    ]\n  },\n  {\n    \"id\": \"26\",\n    \"title\": \"Experimenting with Jetpack Glance\",\n    \"content\": \"Marcel wrote about experimenting with Jetpack Glance that covers a standalone experimental repository to supplement Jetpack Glance with tools that are commonly required for development but not yet available. At the moment, it includes a composable to display RemoteViews inside your app (enabling Live Edit), a debug tool to view and interact with AppWidget snapshots embedded inside the app, and a Material3 Scaffold for AppWidgets.\",\n    \"url\": \"https://medium.com/androiddevelopers/experimenting-with-jetpack-glance-35fbffe520f4\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/720/0*gfm9zG95iVoEX5Gu\",\n    \"publishDate\": \"2022-08-31T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"3\",\n      \"2\"\n    ],\n    \"authors\": [\n      \"9\"\n    ]\n  },\n  {\n    \"id\": \"27\",\n    \"title\": \"Jetpack Compose: Debugging Recomposition\",\n    \"content\": \"Ben Trengove wrote about Debugging recomposition in Compose. Check it out because it also contains a screencast of Ben fixing a performance issue in Jetsnack, a Compose sample. For this, Ben also uses the layout inspector in Android Studio where you can see the recomposition and skip counts of composable functions.\",\n    \"url\": \"https://medium.com/androiddevelopers/jetpack-compose-debugging-recomposition-bfcf4a6f8d37\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/720/1*gwdtRcu1bo_PoH8rwh5E4A.png\",\n    \"publishDate\": \"2022-09-06T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"7\",\n      \"3\",\n      \"5\",\n      \"5\"\n    ],\n    \"authors\": [\n      \"80\"\n    ]\n  },\n  {\n    \"id\": \"28\",\n    \"title\": \"Optimize for Android (Go edition): Lessons from Google apps - Part 1\",\n    \"content\": \"Nikariha started another blog post series about optimizing for Android Go edition. The first part introduces Android Go edition, why you’d want to build for it, and some best practices based on experience building the Gboard and Camera from Google apps.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/09/optimize-for-android-go-lessons-from-google-apps-part-1.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjT34hXV07gVlKKi5X9mjpDGRlawITJfAKr7BpE7E02gtIYVqxYW8RoyjX_SPWJo0KS4PcBNy9rqITsAx0UnXeZp0V6zEoldaBCy9FJ9wyyebLEpPoxJgT6BENWxJqpIrihbpcwUsXO45qhcDAJJ3zTldnKkT8Dw_5VGxl2xYTA2trIVGsczYZLJgKj/s1600/MAD%20App%20Architecture%20launch%20-%20header%20%282%29.png\",\n    \"publishDate\": \"2022-09-07T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"7\"\n    ],\n    \"authors\": [\n      \"73\"\n    ]\n  },\n  {\n    \"id\": \"29\",\n    \"title\": \"ADB Podcast Episodes🎙: Episode 188 - Android 13\",\n    \"content\": \"In Episode 188: Android 13, Chet, Romain, and Tor talk about some of their favorite new features and changes of the new version of Android, both for users and developers.\",\n    \"url\": \"https://adbackstage.libsyn.com/episode-188-android-13\",\n    \"headerImageUrl\": \"https://ssl-static.libsyn.com/p/assets/4/6/e/5/46e518b4880184c288c4a68c3ddbc4f2/ADB_188_Android_13.jpg\",\n    \"publishDate\": \"2022-08-31T23:00:00.000Z\",\n    \"type\": \"Podcast 🎙\",\n    \"topics\": [\n      \"13\"\n    ],\n    \"authors\": [\n      \"31\"\n    ]\n  },\n  {\n    \"id\": \"30\",\n    \"title\": \"Cross device SDK Developer Preview 📱↔️📱\",\n    \"content\": \"We launched the Cross device SDK for Android developer preview, which allows you to build rich multi-device experiences, abstracting away the intricacies involved with working with device discovery, authentication, and connection protocols.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/07/announcing-cross-device-SDK-Developer-Preview-for-Android.html\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1280/0*1CDepdsU40_03H5K.png\",\n    \"publishDate\": \"2022-08-25T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"8\",\n      \"6\"\n    ],\n    \"authors\": [\n      \"84\"\n    ]\n  },\n  {\n    \"id\": \"31\",\n    \"title\": \"CameraX 1.2 is now in Beta\",\n    \"content\": \"CameraX version 1.2 is now in Beta. It introduces a zero shutter lag capture mode along with MlKitAnalyzer, an implementation of ImageAnalysis.Analyzer that handles much of the ML Kit setup for you. MlKitAnalyzer works with both cameraController and cameraProvider workflows and can even handle coordinate transformations between ML Kit output and your PreviewView. Zero shutter lag greatly reduces image capture lag on supported devices by using a circular buffer of captures to get the frame closest to the actual press of the shutter button.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/08/camerax-12-is-now-in-beta.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiS_SSchtaoz90hvgXHZQzwD61bSnd06zOvd7L2sLG-isR8ykrzy7Afk1snnZjCBVkNtMXrmCJIMJfp-gP3X3NMXSbPdVvEgmpqSfTIph-vc_QkBVPDgH8ZQonnMu-XY5Aasi4tp1nmI5jetU2eF4TK_AMOWqA0gLxadk-0dPt2wjpruoDOhxP4PhE_/w681-h202/Android_NewCameraXInBeta_4209x1253.png\",\n    \"publishDate\": \"2022-08-23T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"18\"\n    ],\n    \"authors\": [\n      \"85\"\n    ]\n  },\n  {\n    \"id\": \"32\",\n    \"title\": \"Build Tiles fast with the WearOS Material Tiles Library ⌚\",\n    \"content\": \"Tiles are one of the most used surfaces on Wear OS, providing users glanceable access to the information and actions they need to get things done quickly. We launched the Tiles Material library allowing you to use pre-built Material components such as Button, Chip, CompactChip, TitleChip, CircularProgressIndicator, and Text along with layouts such as PrimaryLayout, EdgeContentLayout, MultiButtonLayout, and MultiSlotLayout to create tiles that embrace the latest Material design for Wear OS. Together with the Tiles Design Kit, they help you to easily follow the Tiles Design Guidelines.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/08/wear-os-tiles-material-library-build-tiles-fast.html\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1278/0*tgTI6u6xZZFHhvSc.png\",\n    \"publishDate\": \"2022-08-23T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"19\",\n      \"2\"\n    ],\n    \"authors\": [\n      \"16\",\n      \"86\"\n    ]\n  },\n  {\n    \"id\": \"33\",\n    \"title\": \"New deep links monitor in Play Console 🔗\",\n    \"content\": \"Deep links allow you to get your users directly to in-app content by accepting traffic from external sources, including the web. Since answering basic questions like “is this URL deep-linked?” or “why is this deep link not working?” can be difficult to answer, many apps have partial, broken, or no deep links configured. To make it easier for you to keep your deep links in good shape, we’ve introduced a new, dedicated Play Console page that gives you a quick but comprehensive snapshot of your current setup along with tooling to help you identify and troubleshoot issues at a glance.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/08/monitor-your-deep-links-in-one-place.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEj42yJ079EEgbm1oAfoXwCKB_LxBw_0iEPsixWd572w6BThCaA5P-O6Ahp7P6SCcCgLZ59rKPtQFkfbpGeVn-f7dk2ef81nSMMqHz3IEw1FL9fAfhiFGgPJZNu5wny2AoWWZ0Ma1PAqGkSGS60eGB59abQHdQ_Hb-_9VdEnS7yg4JLmUIUuW3dNxg0l/w640-h190/Android-DeepLinksWithGooglePlayConsole_4209x1253%20.png\",\n    \"publishDate\": \"2022-08-21T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"12\",\n      \"2\",\n      \"5\"\n    ],\n    \"authors\": [\n      \"87\",\n      \"88\"\n    ]\n  },\n  {\n    \"id\": \"34\",\n    \"title\": \"Celebrating 5 years of Kotlin on Android 🎉\",\n    \"content\": \"Five years ago Android announced official support for the interoperable, mature, production-ready, and open source Kotlin programming language. Since then, JetBrains and Google have been collaborating around the development of Kotlin, and the Kotlin Foundation was co-founded by the two companies; JetBrains developing both the language and tooling has given Kotlin outstanding IDE support. We put together some posts and videos to celebrate the journey and elaborate the milestones of Kotlin on Android with many of the people that helped to make it happen.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/08/celebrating-5-years-of-kotlin-on-android.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjRKysS-6n5YNTJAtLz2PkRNV5XsFnSlod6hwTvKRHbUb0W5pE8RszvmTfFK6GNbh2TKa3dbTP1AjB4pI0NB3agCRb1F4MbP5LQb6Q-8oveLb-mDjqFteWaZnIaztK4W1yONSJ5M6ffWAt-qu9CAu04v0PBIg6OIm9kFHMX6kolmf3zkagX2MIDDOtn/w640-h192/Kotlin%20Header.png\",\n    \"publishDate\": \"2022-08-16T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"10\"\n    ],\n    \"authors\": [\n      \"1\"\n    ]\n  },\n  {\n    \"id\": \"35\",\n    \"title\": \"Mad Skills: Performance 🏎️💨\",\n    \"content\": \"The MAD Skills series on Performance continued with a blog post from \\nBen and a video from Tomáš that covers how to use the Macrobenchmark library along with UIAutomator to help generate Baseline Profiles for you. Baseline Profiles help your app to start and run faster by optimizing critical code paths ahead of time, allowing for a smoother user experience.\",\n    \"url\": \"https://medium.com/androiddevelopers/improving-performance-with-baseline-profiles-fdd0db0d8cc6\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/0*Tztd-PrhMpbWTXGC\",\n    \"publishDate\": \"2022-08-22T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"7\"\n    ],\n    \"authors\": [\n      \"36\"\n    ]\n  },\n  {\n    \"id\": \"36\",\n    \"title\": \"AndroidX releases 🚀\",\n    \"content\": \"In AndroidX, the Wear Compose Version 1.0.1 release fixed a logic bug in ScalingLazyColumn. As mentioned before, we released Wear Tiles Version 1.1. Webkit Version 1.5 added setAlgorithmicDarkeningAllowed, and added support for setting an allow-list of URLs for the configured proxy.\",\n    \"url\": \"https://developer.android.com/jetpack/androidx/versions\",\n    \"headerImageUrl\": \"https://developer.android.com/images/social/android-developers.png\",\n    \"publishDate\": \"2022-10-25T23:00:00.000Z\",\n    \"type\": \"DAC - Android version features\",\n    \"topics\": [\n      \"8\",\n      \"19\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"37\",\n    \"title\": \"A story of App Excellence, featuring Tik Tok\",\n    \"content\": \"Over in video, we covered how TikTok used Android tools to improve app startup and make the user experience more seamless, and how it impacted app usage and Play Store ratings.\",\n    \"url\": \"https://youtu.be/k9Pdgiugleg\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/k9Pdgiugleg/maxresdefault.jpg\",\n    \"publishDate\": \"2022-08-31T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"5\",\n      \"7\",\n      \"2\"\n    ],\n    \"authors\": [\n      \"61\"\n    ]\n  },\n  {\n    \"id\": \"38\",\n    \"title\": \"Design high-quality apps for kids\",\n    \"content\": \"We introduced the Google Play Academy course around designing kids’ apps, ensuring that they’re fun, usable, and appropriate for their target age group. The course covers the framework for rating kids apps on Google Play that teachers across the US use, so you can understand what they’re looking for to help your app stand out.\",\n    \"url\": \"https://youtu.be/-FUmVUPThX8\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/-FUmVUPThX8/hqdefault.jpg\",\n    \"publishDate\": \"2022-08-31T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"12\",\n      \"2\"\n    ],\n    \"authors\": [\n      \"61\"\n    ]\n  },\n  {\n    \"id\": \"39\",\n    \"title\": \"MAD about Media\",\n    \"content\": \"Avish, our summer Android DevRel Engineer intern, discussed modern approaches to creating Android media apps leveraging experience in converting the Universal Android Media Player (UAMP) media playback sample app to Compose, updating it to use modern libraries such as Media3.\",\n    \"url\": \"https://medium.com/androiddevelopers/mad-about-media-f536f7d601c\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/1*BKAchEMpYdP3dEyaIAP5xA.png\",\n    \"publishDate\": \"2022-08-25T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"18\"\n    ],\n    \"authors\": [\n      \"61\"\n    ]\n  },\n  {\n    \"id\": \"40\",\n    \"title\": \"Top Tips for Adopting Android’s Notification Permission\",\n    \"content\": \"Terence covered tips to improve your app’s user experience with notifications before targeting Android 13, as well as how to test your app’s integration with the permission without flashing different OS versions onto your device.\",\n    \"url\": \"https://medium.com/androiddevelopers/top-tips-for-adopting-androids-notification-permission-bf69afd677b8\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/1*XQmi35H84FdYhY_ONP6ntQ.png\",\n    \"publishDate\": \"2022-08-23T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"13\",\n      \"6\"\n    ],\n    \"authors\": [\n      \"89\"\n    ]\n  },\n  {\n    \"id\": \"41\",\n    \"title\": \"Jetpack Compose Accompanist — An FAQ.\",\n    \"content\": \"Ben wrote a FAQ on the Jetpack Compose Accompanist, a labs-like environment for new Compose APIs. Accompanist is used to help fill known gaps in the Compose toolkit, experiment with new APIs and to gather insight into the development experience of building a Compose library. The goal of Accompanist is to eventually upstream libraries into the official toolkit. (at which point they will be deprecated and removed from Accompanist) Current libraries in Accompanist include support for Flow Layouts, Pager, Navigation Transitions, and Swipe Refresh.\",\n    \"url\": \"https://medium.com/androiddevelopers/jetpack-compose-accompanist-an-faq-b55117b02712\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/1*w_MA7M6H9HpwdWb_fx-2IA.png\",\n    \"publishDate\": \"2022-08-18T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"3\"\n    ],\n    \"authors\": [\n      \"80\"\n    ]\n  },\n  {\n    \"id\": \"42\",\n    \"title\": \"Introducing the MAD Skills series on Performance\",\n    \"content\": \"It’s time for another series of MAD Skills on Performance! Performance spans every aspect of Android development, and as part of Modern Android Development we’re aiming to make it more approachable and user-friendly. We have also found direct correlations between improved app performance, user satisfaction and business metrics.\",\n    \"url\": \"https://medium.com/androiddevelopers/introducing-the-mad-skills-series-on-performance-7dbb26e8b17f\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgkWBi6t47sZvF2EqduUT_a38uamN_jLbjDIoada1oN9PSbkyyduU1f_x6t4H8gX1ghq11Wyt09dBjw-l3efO5EO62AvdrVELnk4qc6Xft96Fk_ViJ8xipsPXirDnvVoYw44tl-gJqUHqOXxrdbPbZjjGwXGmoLL992o_5AMdkpnWyoL0oz8HrAJagH/w1200-h630-p-k-no-nu/unnamed_%281%29.png.jpeg\",\n    \"publishDate\": \"2022-08-09T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"7\",\n      \"5\"\n    ],\n    \"authors\": [\n      \"36\"\n    ]\n  },\n  {\n    \"id\": \"43\",\n    \"title\": \"MAD Skills: Important Performance Metrics\",\n    \"content\": \"Before you begin to work with performance effectively, we recommend you make yourself familiar with key performance metrics. By understanding what metrics you should look at, you will make inspecting, improving and monitoring performance easier.\",\n    \"url\": \"https://medium.com/androiddevelopers/important-performance-metrics-c7dacf018eb3\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjYdlNQTXP5pTpqN3fQwfF__WHtEmflMVuLS6ErWorUPYM9MwThUmwuBfFhDMVtw5X1jVmchC9z20Bl_yD7M_thVbCmhRJLyZqh3sHZBm6Sryz_xyu4cvusk_xx1kJCh5ANM-TtsvG1WwqMUPllTZegJlstUj3KDesGJ2Xrh6AsLU9HvaFCLT4RLZd7/w1200-h630-p-k-no-nu/resize%20play_10-android_dev.png\",\n    \"publishDate\": \"2022-08-10T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"7\"\n    ],\n    \"authors\": [\n      \"36\"\n    ]\n  },\n  {\n    \"id\": \"44\",\n    \"title\": \"MAD Skills: Inspecting Performance\\n\",\n    \"content\": \"This MAD Skills article on inspecting performance introduces you to tools and methods that help when your code’s performance. When you inspect performance you make sure that what’s happening in your app aligns with what you expect to happen.\",\n    \"url\": \"https://medium.com/androiddevelopers/inspecting-performance-95b76477a3d7\",\n    \"headerImageUrl\": \"https://1.bp.blogspot.com/-9MiK78CFMLM/YQFurOq9AII/AAAAAAAAQ1A/lKj5GiDnO_MkPLb72XqgnvD5uxOsHO-eACLcBGAsYHQ/w1200-h630-p-k-no-nu/Android-Compose-1.0-header-v2.png\",\n    \"publishDate\": \"2022-08-14T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"7\",\n      \"5\"\n    ],\n    \"authors\": [\n      \"36\"\n    ]\n  },\n  {\n    \"id\": \"45\",\n    \"title\": \"Consuming flows safely in Jetpack Compose\",\n    \"content\": \"Collecting flows in a lifecycle-aware manner is the recommended way to collect flows on Android. If you’re building an Android app with Jetpack Compose, use the collectAsStateWithLifecycle API to collect flows in a lifecycle-aware manner from your UI.\",\n    \"url\": \"https://medium.com/androiddevelopers/consuming-flows-safely-in-jetpack-compose-cde014d0d5a3\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/720/1*LL7FLWzjT4c6bQdGlvdz7w.png\",\n    \"publishDate\": \"2022-08-09T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"3\",\n      \"8\",\n      \"4\"\n    ],\n    \"authors\": [\n      \"23\"\n    ]\n  },\n  {\n    \"id\": \"46\",\n    \"title\": \"Brushing up on Compose Text coloring\",\n    \"content\": \"Alejandra Stamato’s last article covered compose brush text coloring, and this week she taught us how to take it a step further and add animation to brush text coloring. She covers using the animation APIs in conjunction with the Brush APIs, demonstrating these with a candy cane shimmer effect and a back-and-forth shimmer effect.\",\n    \"url\": \"https://medium.com/androiddevelopers/brushing-up-on-compose-text-coloring-84d7d70dd8fa\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/720/1*PZK1BRIYM22iLQhexPGT1Q.png\",\n    \"publishDate\": \"2022-07-24T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"3\"\n    ],\n    \"authors\": [\n      \"91\"\n    ]\n  },\n  {\n    \"id\": \"47\",\n    \"title\": \"Final Android 13 Beta update, official release is next!\",\n    \"content\": \"We’re just a few weeks away from the official release of Android 13! Meanwhile, we published the last Beta for your testing and development. We reached Platform Stability at Beta 3, so all app-facing surfaces are final, including SDK and NDK APIs, app-facing system behaviors, and restrictions on non-SDK interfaces. With these and the latest fixes and optimizations, Beta 4 gives you everything you need to complete your testing.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/07/Final-Android-13-Beta-update-official-release-is-next.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiKBGMJx7yKi1RuRF9Q1X-1GOgfCvJ7XVIhNZlsmYgeabTPyljHhEOr2F0iGkF2BM7jeE1HYnL75GXllESyWgpEZEQWm9xfKU5a8kDgrvS5-ZZN0eTq0QaTsCBOAFkJzGX9kBTZxfo_4p6O3DYkXVqsBMRynTq1Mw3EDq3hwEL5OcoCrSQ8rOvFrraK/w1200-h630-p-k-no-nu/Compose%20Blog%20social.jpg\",\n    \"publishDate\": \"2022-07-12T23:00:00.000Z\",\n    \"type\": \"DAC - Android version features\",\n    \"topics\": [\n      \"13\"\n    ],\n    \"authors\": [\n      \"62\"\n    ]\n  },\n  {\n    \"id\": \"48\",\n    \"title\": \"10 years of Google Play\",\n    \"content\": \"In 2012, the team opened the (digital) doors of Google Play. A decade later, more than 2.5 billion people in over 190 countries use Google Play every month to discover apps, games and digital content. And more than 2 million developers work with us to build their businesses and reach people around the globe! Congratulations to the Google Play team for this huge milestone.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/03/celebrating-10-years-of-google-play.html\",\n    \"headerImageUrl\": \"https://developer.android.com/images/social/android-developers.png\",\n    \"publishDate\": \"2022-07-24T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"12\"\n    ],\n    \"authors\": [\n      \"63\"\n    ]\n  },\n  {\n    \"id\": \"49\",\n    \"title\": \"Android Basics with Compose Unit 3 available\",\n    \"content\": \"The Unit 3 of Android Basics with Compose course is available already! Unit 3 covers how to build apps that display a list of data and how to make your apps more beautiful with Material Design.\",\n    \"url\": \"https://developer.android.com/courses/android-basics-compose/course\",\n    \"headerImageUrl\": \"https://www.gstatic.com/devrel-devsite/prod/vab7ee6e3641f10848d404faa598f256587df1a361a1e70cd114230c2961b73d9/android/images/lockup.svg\",\n    \"publishDate\": \"2022-08-02T23:00:00.000Z\",\n    \"type\": \"Codelab\",\n    \"topics\": [\n      \"2\",\n      \"3\",\n      \"10\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"50\",\n    \"title\": \"Jetpack Compose 1.2 is now stable\",\n    \"content\": \"This release contains new features like downloadable fonts, lazy grids, and improvements for tablets and Chrome OS with better focus, mouse, and input handling.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/07/jetpack-compose-1-2-is-now-stable.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEj1vg5QdkR-Hj7oQ3yueza1VGYFrNOBbuAxRQycRYGO6HLR-Hf2LR9DHT__OxVFecRYFZbVq6cYg6ca15h7kkKO99zzheFMB_x6MdTO74DaJpTR933pmrkJ-pWVq_7fEmN38nYHQv2u1l7-Ukk8RtNPrtGnn-ChdYwcbbx8Q-MnbJ3z3BjSj3U0Q-YX/w1200-h630-p-k-no-nu/header-image-predictive-back-blog%20%281%29.png\",\n    \"publishDate\": \"2022-07-26T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"3\"\n    ],\n    \"authors\": [\n      \"65\"\n    ]\n  },\n  {\n    \"id\": \"51\",\n    \"title\": \"Compose for Wear OS is now 1.0\",\n    \"content\": \"Compose for Wear OS makes building apps for Wear OS easier, faster, and more intuitive by following the declarative approach and offering powerful Kotlin syntax. Moving forward, Compose for Wear OS is our recommended approach for building user interfaces for Wear OS apps.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/07/compose-for-wear-os-10-stable.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjsdruRjulgaFFtqwrnp6Z0mzIAhmMzJZIOUjVqugCB3i5noivoLOVecpMNBQGVIsG_kjkzthRTpibL-CEmlsn5nZJUhnSkkdhEe0V2yaNPQt2l-FGh0sQz1JnOZVRHRDZIr72twcPZQL7Q0kdgb-JzxgKJlZSsESJkMLuAkvqCfyXoE7d-XxFQYVoJ/w1200-h630-p-k-no-nu/Google_Android_DeveloperPoweredCTS_4209x1253.jpg\",\n    \"publishDate\": \"2022-07-26T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"3\",\n      \"19\"\n    ],\n    \"authors\": [\n      \"75\"\n    ]\n  },\n  {\n    \"id\": \"52\",\n    \"title\": \"AndroidX releases 🚀\",\n    \"content\": \"Jetpack Compose 1.2, Compose for Wear OS 1.0, Core splashscreen v1.0, and Profile Installer v1.2 went stable. In RC, you can find AppCompat v1.5, Compose Compiler v1.3 (that brings support to Kotlin version 1.7.10), Emoji2 v1.2, and ShareTarget v1.2. Lastly, Wear Tiles v1.1 reached its first beta version!\",\n    \"url\": \"https://developer.android.com/jetpack/androidx/versions/all-channel\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEirdkVqgyYoZDm0ktFFXjyIATaFKJUCVU0lIzQpTw4dlJjvqruWxLJn5mJ5xHoZijqVQ-poghVIGWGCpZM0Nb_bzx274kr1Lo_nn0PvEzMXcU_DgNEFrKzw5HtuE_vA9zfRVy8RDuiAIgC_aDVhHmGdqSLhzsPK5Pj2m3QNB4lzsf4E0VkbctqiowND/w1200-h630-p-k-no-nu/Android-discountinuing-kotlin-synthetics-for-views-social%20%281%29.png\",\n    \"publishDate\": \"2022-08-02T23:00:00.000Z\",\n    \"type\": \"Jetpack release 🚀\",\n    \"topics\": [\n      \"8\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"53\",\n    \"title\": \"Make your app large screen ready\",\n    \"content\": \"Learn how to get started with large screen support and why it is so important. Accurately handling orientation changes, aspect ratios, and adaptive layouts may seem challenging, but with new large screen experiences in mind, multiple form factors bring new possibilities to your users.\",\n    \"url\": \"https://medium.com/androiddevelopers/make-your-app-large-screen-ready-baf8fe505ae7\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1200/0*1hkxEoydoX8GzK9N\",\n    \"publishDate\": \"2022-07-19T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": [\n      \"90\"\n    ]\n  },\n  {\n    \"id\": \"54\",\n    \"title\": \"Get familiar with Wear OS 3 (without a physical device)\",\n    \"content\": \"You don’t need a physical device to test your Wear apps. Read this article to take a brief look at unique UI surfaces on Wear OS, create a Wear emulator and explore it from a user perspective.\",\n    \"url\": \"https://medium.com/androiddevelopers/get-familiar-with-wear-os-3-without-a-physical-device-e7962c97f02b\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1200/1*3M48bGiXnBX8y83eYLgFtw.png\",\n    \"publishDate\": \"2022-07-20T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"19\",\n      \"2\"\n    ],\n    \"authors\": [\n      \"86\"\n    ]\n  },\n  {\n    \"id\": \"55\",\n    \"title\": \"Jetpack Compose Interop: Using Compose in a RecyclerView\",\n    \"content\": \"What versions of Compose and RecyclerView do you need to use to get the best performance? In addition, you’ll understand how the interop works under the hood.\",\n    \"url\": \"https://medium.com/androiddevelopers/jetpack-compose-interop-using-compose-in-a-recyclerview-569c7ec7a583\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1200/1*aBNjsK7y35V05OKNQ2oIZg.png\",\n    \"publishDate\": \"2022-07-21T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"3\",\n      \"2\"\n    ],\n    \"authors\": [\n      \"68\"\n    ]\n  },\n  {\n    \"id\": \"56\",\n    \"title\": \"Brushing up on Compose Text coloring\",\n    \"content\": \"A very colorful blog post about how to work with Brush API together with TextStyle to achieve complex text coloring like giving a gradient to your text in a simple way.\",\n    \"url\": \"https://medium.com/androiddevelopers/brushing-up-on-compose-text-coloring-84d7d70dd8fa\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1200/1*PZK1BRIYM22iLQhexPGT1Q.png\",\n    \"publishDate\": \"2022-07-24T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"3\",\n      \"2\"\n    ],\n    \"authors\": [\n      \"91\"\n    ]\n  },\n  {\n    \"id\": \"57\",\n    \"title\": \"Animating brush Text coloring in Compose 🖌️\",\n    \"content\": \"Learn how to animate gradients in your text using the Brush API and Compose animations. Go check them out, I can’t stop looking at those animations now!\",\n    \"url\": \"https://medium.com/androiddevelopers/animating-brush-text-coloring-in-compose-%EF%B8%8F-26ae99d9b402\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1200/1*9fEDrtJES1CQEVlyI7WjgQ.png\",\n    \"publishDate\": \"2022-07-31T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"3\",\n      \"2\"\n    ],\n    \"authors\": [\n      \"91\"\n    ]\n  },\n  {\n    \"id\": \"58\",\n    \"title\": \"Prepare your app to support predictive back gestures\",\n    \"content\": \"Predictive back gestures is a feature that will be available in future versions of Android. However, to give you more time to adopt it, we made it available in the developer options of Android 13 Beta 4. Read the blog post for details on how to try out the new gesture and support it in your apps. Spoiler alert: it’s straightforward for most apps!\",\n    \"url\": \"https://android-developers.googleblog.com/2022/07/prepare-your-app-to-support-predictive-back-gestures.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEi9NXOkaUCvb2KLyrnCp4DWpr2dyryXzHsqvX94Dcrw3r_5znwMZFy6PwmaHJj25D0DKYcZlF8Jac5C0KhM1s2j_mEc0VULf-eiCpT3JGbYgI_jg105SyUEwNG7w2dvF-60npxBgZidqgXqx7A1iWRftv9lOZrM9OAfc4f105met0ZauGQ5hRQC0_wE/w1200-h630-p-k-no-nu/image3.jpg\",\n    \"publishDate\": \"2022-07-28T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"13\"\n    ],\n    \"authors\": [\n      \"92\",\n      \"93\",\n      \"94\"\n    ]\n  },\n  {\n    \"id\": \"59\",\n    \"title\": \"ADB Podcast Episodes🎙187: System UI: A Retrospective\",\n    \"content\": \"In this episode Tor and Chet meet Dan Sandler and Adam Cohen from the SystemUI team. They dip into a bit of history, talking about where things were at when they joined the team, and how things have developed in the many years since. They also talk about how to expose (or not) gestures and features in a UI system.\",\n    \"url\": \"https://adbackstage.libsyn.com/episode-187-system-ui-a-retrospective\",\n    \"headerImageUrl\": \"https://ssl-static.libsyn.com/p/assets/9/4/d/b/94dbe077f2f14ee640be95ea3302a6a1/ADB184_Skia_and_AGSL.png\",\n    \"publishDate\": \"2022-07-24T23:00:00.000Z\",\n    \"type\": \"Podcast 🎙\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": [\n      \"32\",\n      \"31\"\n    ]\n  },\n  {\n    \"id\": \"60\",\n    \"title\": \"Developer-Powered CTS (CTS-D) 🧪\",\n    \"content\": \"The Compatibility Test Suite (CTS) is a key part of the Android Compatibility Program that helps to ensure that devices provide a stable, consistent environment for your apps. To enhance CTS, we are adding a new test suite called CTS-D that is built and run by developers like you. You can build and contribute test cases to CTS-D to help catch pain points that you are seeing in the field — places where device behavior doesn’t match the Android public APIs and the Android Compatibility Definition Document (CDD).\",\n    \"url\": \"https://android-developers.googleblog.com/2022/06/developer-powered-cts-cts-d.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjsdruRjulgaFFtqwrnp6Z0mzIAhmMzJZIOUjVqugCB3i5noivoLOVecpMNBQGVIsG_kjkzthRTpibL-CEmlsn5nZJUhnSkkdhEe0V2yaNPQt2l-FGh0sQz1JnOZVRHRDZIr72twcPZQL7Q0kdgb-JzxgKJlZSsESJkMLuAkvqCfyXoE7d-XxFQYVoJ/w1200-h630-p-k-no-nu/Google_Android_DeveloperPoweredCTS_4209x1253.jpg\",\n    \"publishDate\": \"2022-06-22T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"6\",\n      \"13\"\n    ],\n    \"authors\": [\n      \"98\"\n    ]\n  },\n  {\n    \"id\": \"61\",\n    \"title\": \"Independent versioning of Jetpack Compose libraries ✒️\",\n    \"content\": \"We announced that the various Jetpack Compose libraries will move to independent versioning schemes, making it easier to incrementally upgrade your application and stay up-to-date with the latest Compose features.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/06/independent-versioning-of-Jetpack-Compose-libraries.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjolHnYXFnb81t2qu38Z8BPxU0QNitCVulwRwgZlijGDwCbcSSPETvSVr9apTSV_eDknzPDs1BwccZU_lYr15czYU_ddiXete76bVxWWIhNE29-PfOCxMzvashjOwvGWrzZ4rynym-k4aNQ4c-tmN7v4O5vh0iaRpFZTMuYTFqjFLrNpHNlOwSyZyf6/w1200-h630-p-k-no-nu/unnamed_%281%29.png.jpeg\",\n    \"publishDate\": \"2022-06-28T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"3\"\n    ],\n    \"authors\": [\n      \"65\"\n    ]\n  },\n  {\n    \"id\": \"62\",\n    \"title\": \"Notes from Google Play: making Play work for everyone ▶️\",\n    \"content\": \"In the latest edition of Notes from Google Play, we touched on the Play Integrity API, the Data Safety section, the Privacy Sandbox on Android, and the newly-launched Google Play SDK Index, which provides data and insights about over 100 of the most widely used commercial SDKs. We covered new subscription capabilities that allow you to create multiple base plans and offers for each subscription, as well as the option to lower prices starting at the equivalent of 5 US cents to adapt to local purchasing power.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/06/notes-from-google-play-making-play-work.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEicboZEaxs6kOlhHSoRE__yCFdTkFvVW7z9ksAfVlkdCVgNQzkG1B1z4RVCV6l3g-Up3ZPGchGjq5idAKV5prrVVy9T2o6MtJh-iXZtUKcKyNY1Cqt39bi5VzwZ2CLy7N3FANcklla-mHIGboZzvNRl3eN5ZMvjl29tjtGGLGubVKIYwIUCh6q2-FhT/w1200-h630-p-k-no-nu/AD%20Blog%20Social.png\",\n    \"publishDate\": \"2022-06-21T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"12\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"63\",\n    \"title\": \"Dark theme testing in Pre-Launch Report 🕶️\",\n    \"content\": \"After you upload and publish a test Android App Bundle to Google Play, we install it on a set of Android devices, launch and crawl your app for several minutes, and compile your results into the pre-launch report. We’ve introduced a new test in the Pre-Launch Report that runs accessibility checks on a device switched to dark theme; this can detect color contrast issues that make it difficult to differentiate text and icons from a background. \",\n    \"url\": \"https://support.google.com/googleplay/thread/170731936/dark-theme-test-now-included-in-pre-launch-report\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgJtTKEaaohJa7b5H5mkmCNu1LbQ3FPrPv0hSVWdEacemvtEHRWhk-DCi5aEartYwL0OMg6NOHJ1Vnn1fqeJ5cMc7Bl08SY7JcEBpKp5Vde-y_VDIPoVNlhb5VZbyv4PlauW_xpvnf6iS8yszMOnuo5w0Rw5NmYZ45reEvulY2KgGoPaG9NZ6H8hO2b/w1200-h630-p-k-no-nu/Android_SandboxPreview_V2_1024x512.jpg\",\n    \"publishDate\": \"2022-07-10T23:00:00.000Z\",\n    \"type\": \"Docs 📑\",\n    \"topics\": [\n      \"12\",\n      \"6\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"64\",\n    \"title\": \"Performance tips to achieve App Excellence\",\n    \"content\": \"The Performance tips to achieve App Excellence video covered five app performance issues along with the tools that Android Studio and Google Play Console provide to help you diagnose and troubleshoot them.\",\n    \"url\": \"https://www.youtube.com/watch?v=VJItLXb7_V8\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/VJItLXb7_V8/maxresdefault.jpg\",\n    \"publishDate\": \"2022-07-05T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"7\",\n      \"2\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"65\",\n    \"title\": \"Making Sense of Intent Filters in Android 13\",\n    \"content\": \"Before Android 13, when an app registered an exported component in its manifest and added an <intent-filter>, the component could be started by any explicit intent — even those that do not match the intent filter. In some circumstances this can allow other apps to trigger internal-only functionality.\\n\\nThis behavior has been updated in Android 13. Now intents that specify actions and originate from external apps are delivered to an exported component if and only if the intent matches its declared <intent-filter> elements.\",\n    \"url\": \"https://medium.com/androiddevelopers/making-sense-of-intent-filters-in-android-13-8f6656903dde\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1200/1*PX8VuYcLzaC-AvOtSPgMRw.png\",\n    \"publishDate\": \"2022-07-05T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"13\",\n      \"2\"\n    ],\n    \"authors\": [\n      \"99\"\n    ]\n  },\n  {\n    \"id\": \"66\",\n    \"title\": \"Customizing AnimatedContent in Jetpack Compose 🌟\",\n    \"content\": \"Rebecca covers using AnimatedContent to transition between different composables with a smoother and more custom transition effect. Even the default behavior of AnimatedContent can make a big difference to the look and feel of your app, without much effort.\",\n    \"url\": \"https://medium.com/androiddevelopers/customizing-animatedcontent-in-jetpack-compose-629c67b45894\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1200/1*Yu-W3qMxx1YyPm_AJMcXGg.png\",\n    \"publishDate\": \"2022-06-30T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"3\"\n    ],\n    \"authors\": [\n      \"96\"\n    ]\n  },\n  {\n    \"id\": \"67\",\n    \"title\": \"Jetpack Compose Stability Explained\",\n    \"content\": \"Ben does a detailed exploration of how Compose determines the stability of each parameter of your composables to see what can be skipped during recomposition, including using compiler reports to determine what stability is being inferred about your classes. \",\n    \"url\": \"https://medium.com/androiddevelopers/jetpack-compose-stability-explained-79c10db270c8\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1200/1*iLEtRB3BpIkD6CgjWFP2RQ.png\",\n    \"publishDate\": \"2022-06-29T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"3\"\n    ],\n    \"authors\": [\n      \"80\"\n    ]\n  },\n  {\n    \"id\": \"68\",\n    \"title\": \"Migrating to the new coroutines 1.6 test APIs\",\n    \"content\": \"kotlinx.coroutines 1.6 introduces a set of new testing APIs, and the previous testing APIs are now deprecated.\\n \\nMarton talked about how we’ve migrated some of our own samples to the new APIs, covering a bunch of the necessary work for most Android projects.\",\n    \"url\": \"https://medium.com/androiddevelopers/migrating-to-the-new-coroutines-1-6-test-apis-b99f7fc47774\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1200/1*XQmi35H84FdYhY_ONP6ntQ.png\",\n    \"publishDate\": \"2022-06-28T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"10\",\n      \"6\"\n    ],\n    \"authors\": [\n      \"1\"\n    ]\n  },\n  {\n    \"id\": \"69\",\n    \"title\": \"Android 13 beta 3\",\n    \"content\": \"We released the third beta of Android 13! Android 13 has been built on our core themes of privacy and security, developer productivity, and large screen support. ​​Beta 3 takes Android 13 to Platform Stability, which means that the developer APIs and all app-facing behaviors are now final; you can confidently develop and release your updates. Read all about it in the post!\\n\",\n    \"url\": \"https://android-developers.googleblog.com/2022/06/android-13-beta-3-platform-stability.html\",\n    \"headerImageUrl\": \"https://developer.android.com/images/social/android-developers.png\",\n    \"publishDate\": \"2022-06-07T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"1\",\n      \"13\"\n    ],\n    \"authors\": [\n      \"14\"\n    ]\n  },\n  {\n    \"id\": \"70\",\n    \"title\": \"Google Play @ Google I/O - 3 updates you need to know\",\n    \"content\": \"In this video and blog post, Phalene tells us about the top three things to know about Google Play from Google I/O. These include updates on custom store listings, introducing more developers to the LiveOps beta, and new subscription capabilities. Learn more about these three topics in the video or blog post!\\n\",\n    \"url\": \"https://android-developers.googleblog.com/2022/06/google-play-google-io-3-updates-you-need-to-know_01537187872.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEg40pvJlLB9LP1shkyFOq4pIexSrdI-kSH9uPxMEdhjynUPm2Zdfy4W1sHb6v0d5hZqycnWP9qTVjxHu0DADwL2BrtBwkPrOOIFXA8-H2RC6W70ehcnYUTgKXy8eRvwvWDeu2J-0iVmMgkd4c1lyYUBnZi39mPVTJV5Ke83DvWMBioeLOWPivE0Tpvv/w1200-h630-p-k-no-nu/Android-io-spotlight-modern-android-development-social.png\",\n    \"publishDate\": \"2022-06-12T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"1\",\n      \"12\"\n    ],\n    \"authors\": [\n      \"76\"\n    ]\n  },\n  {\n    \"id\": \"71\",\n    \"title\": \"Privacy Sandbox Developer Preview 3\",\n    \"content\": \"The Privacy Sandbox on Android aims to develop new solutions that preserve user privacy while enabling effective, personalized advertising experiences for apps. Now it is in Developer Preview 3, which adds APIs and resources for conversion measurement and remarketing use cases; this allows you to begin testing and evaluating impact on all key APIs for Privacy Sandbox on Android.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/06/privacy-sandbox-developer-preview-3.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjg5R2hd0VZp__xKUUgs6-tfKHEoabMPqo2aY6uoGvzre-9E4gUlz6RbGsrE-Txszbrc3OaNL9r2TshsZmzGhEiM3M-_eO8M39K6ljm9NrX2BMHRLHM3HeF04YgJf8l4Z1-kNaP9YV8BCRe3n2zTUTSx3FOvA5IRc4PCjVPiJ7CEw7M7Y7uAJLVd7WQ/w1200-h630-p-k-no-nu/Android-GoogleIO3thingstoknowaboutFormFactors_4209x1253.png\",\n    \"publishDate\": \"2022-06-15T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"11\"\n    ],\n    \"authors\": [\n      \"77\"\n    ]\n  },\n  {\n    \"id\": \"72\",\n    \"title\": \"ADB Podcast Episodes🎙186: Live Edit\",\n    \"content\": \"In this episode, we talk with Alan and Esteban from Android Studio about the new \\\"Live Edit\\\" feature recently launched at Google I/O in the Electric Eel canary build. We dive into the technology -- how it works, what the technical challenges are, and its current state.\",\n    \"url\": \"https://adbackstage.libsyn.com/episode-186-live-edit\",\n    \"headerImageUrl\": \"http://assets.libsyn.com/show/332855?height=250&width=250&overlay=true\",\n    \"publishDate\": \"2022-06-07T23:00:00.000Z\",\n    \"type\": \"Podcast 🎙\",\n    \"topics\": [\n      \"5\",\n      \"5\"\n    ],\n    \"authors\": [\n      \"31\",\n      \"30\",\n      \"32\"\n    ]\n  },\n  {\n    \"id\": \"73\",\n    \"title\": \"AndroidX releases\",\n    \"content\": \"Let’s take a look at what’s been up with AndroidX releases since the last episode of Now in Android. We have a few new features that are stable including Wear Watchface, Games-Activity, Benchmark, and Annotation\",\n    \"url\": \"https://developer.android.com/jetpack/androidx/versions/all-channel\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEizEliXuy2sTNnhRQtwNAvL46sKpcBLbHiuROrEiOtPDTJ1D0eQlWDDUjspVECqlDw3_sLhFzJO8SCrGJuFaT2QQ7Iezi0xrkhD7yWqpbacVLRC8aX-1bx0aZ-RM1k_S-S0LFTE0PrvX-BlNsmmilGCGMdvRk0v6zhHs8nKwdFv-AluPQIRjAtFx938/w1200-h630-p-k-no-nu/Android-GoogleIOSpotlightPrivacyAndSecurity_1024x512.png\",\n    \"publishDate\": \"2022-06-22T23:00:00.000Z\",\n    \"type\": \"Jetpack release 🚀\",\n    \"topics\": [\n      \"8\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"74\",\n    \"title\": \"Google I/O recaps ⏱ - Modern Android Development!\\n\",\n    \"content\": \"Our goal is to make developing beautiful and engaging Android apps as fast and easy as possible. We want to take on the complex parts of building apps so that you can focus on your app’s features and deliver high quality experiences to your users.\\n\\nWe call this approach Modern Android Development (or MAD for short!) and deliver it through a suite of tools, libraries and guidance. At Google I/O we announced a number of updates and additions to our MAD offerings; here’s a recap of the three largest announcements.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/05/io22-spotlights-mad.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgpSQB-YhRGzOJ4X1hoh1DlnMx9cOxfUKdoriSncXDPuaXZXpiXGYBXpxJUsMVKPhGTbTKkT-Gn3g52Tqcy7Alyv6gkMQEKd7twzAj1JbR2DwdFUZYbIcnMgXD2PeRrkTq9jZw8XId5t0D9im6i3XkVCK-YIk10g3E7ut_pLnVdA9tVnGJU5T7XFLi8/w1200-h630-p-k-no-nu/unnamed.png\",\n    \"publishDate\": \"2022-05-22T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"3\",\n      \"5\"\n    ],\n    \"authors\": [\n      \"57\"\n    ]\n  },\n  {\n    \"id\": \"75\",\n    \"title\": \"Google I/O recaps ⏱ - Form factors!\",\n    \"content\": \"With close to half a billion cars, TVs, watches and laptops running on Android, it is more important than ever for apps to work seamlessly across every device. This year at I/O, we renewed our focus on form factors and announced major updates for Wear OS and Large Screens. To help you get to the bottom of what’s new, here are the three things you need to know about Form Factors at Google I/O\",\n    \"url\": \"https://android-developers.googleblog.com/2022/05/form-factors-google-io-22.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEj7lx3OtPkMXTr0cwNlItkSUwDQYcTAO1cP-fE8n_NLtnZQ5uBnoP-y0MfENfmuB_2HGRUbmrx_ADz4FmDW8VkBmp_WcdISO0uQiO4Dw2yi9XjBUPqwjX2o24j8lUEhvYWJidFi6ra9WrjHxO1sTCjwBZrLyXHhIjgbRZzFQX-oUOKeqvf1dXg4XQ-A/w1200-h630-p-k-no-nu/Android_ImplementingColor_1024x512.jpg\",\n    \"publishDate\": \"2022-05-30T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"19\",\n      \"2\",\n      \"16\",\n      \"15\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"76\",\n    \"title\": \"Google I/O recaps ⏱ - Android Privacy, Platform & Security!\",\n    \"content\": \"Amidst the whirlwind of content at Google I/O, we shared huge announcements involving privacy, security, and the Android platform. Read on for the details, and don’t forget to watch the topic playlist on YouTube.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/06/privacy-security-google-io-22.html\",\n    \"headerImageUrl\": \"https://developer.android.com/images/social/android-developers.png\",\n    \"publishDate\": \"2022-06-05T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"11\",\n      \"13\"\n    ],\n    \"authors\": [\n      \"95\"\n    ]\n  },\n  {\n    \"id\": \"77\",\n    \"title\": \"Spot your UI jank using CPU profiler in Android Studio\",\n    \"content\": \"Takeshi wrote about spotting your UI jank using the CPU profiler in Android Studio. The article goes through a step by step walkthrough about how to use the new jank detection UI in Android Studio Chipmunk including how to record a trace, and how to inspect janky frames.\",\n    \"url\": \"https://medium.com/androiddevelopers/spot-your-ui-jank-using-cpu-profiler-in-android-studio-9a4c41a54dab\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1200/1*FkkN0FugRiSDxhfp1TQz-Q.png\",\n    \"publishDate\": \"2022-05-15T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"5\"\n    ],\n    \"authors\": [\n      \"81\"\n    ]\n  },\n  {\n    \"id\": \"78\",\n    \"title\": \"Custom Canvas Animations in Jetpack Compose ✨\",\n    \"content\": \"Rebecca Franks wrote about custom Canvas animations in Jetpack Compose. Using the Animatable states and some side-effects, you’ll be able to achieve custom animations as you were able to do with ValueAnimator in the View system.\",\n    \"url\": \"https://medium.com/androiddevelopers/custom-canvas-animations-in-jetpack-compose-e7767e349339\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1200/1*16bn5V--jLMAJLCWspst2Q.png\",\n    \"publishDate\": \"2022-05-16T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"3\"\n    ],\n    \"authors\": [\n      \"96\"\n    ]\n  },\n  {\n    \"id\": \"79\",\n    \"title\": \"Implementing Dynamic Color: Lessons from the Chrome team\",\n    \"content\": \"If you’re interested in Material You dynamic color, Rebecca Gutteridge talks about how the Chrome team implemented it and the things they kept in mind such as accessibility, custom colors, incognito, and more. It also comes with a really helpful list of recommendations from the designers and developers of the team.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/05/implementing-dynamic-color-lessons-from.html\",\n    \"headerImageUrl\": \"https://developer.android.com/images/social/android-developers.png\",\n    \"publishDate\": \"2022-05-26T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"2\",\n      \"3\"\n    ],\n    \"authors\": [\n      \"97\"\n    ]\n  },\n  {\n    \"id\": \"80\",\n    \"title\": \"ViewModel: One-off event antipatterns\",\n    \"content\": \"We’re very opinionated about what to do with ViewModel events in our UI events Architecture guidance, and this blog post explains why the alternatives to our recommendation might bring higher engineering costs to developers and a worse user experience.\",\n    \"url\": \"https://medium.com/androiddevelopers/viewmodel-one-off-event-antipatterns-16a1da869b95\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1200/0*ROW1i16idpH-rHO-.png\",\n    \"publishDate\": \"2022-05-31T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": [\n      \"23\"\n    ]\n  },\n  {\n    \"id\": \"81\",\n    \"title\": \"Diving Into Compose — Lessons Learned While Building Maps Compose\",\n    \"content\": \"If you’re interested in Compose and Google Maps, Chris Arriola wrote about the lessons learned while building Maps Compose. You’ll see how they took advantage of Kotlin features, how to aim for binary compatibility, subcomposition, and more!\",\n    \"url\": \"https://medium.com/androiddevelopers/diving-into-compose-lessons-learned-while-building-maps-compose-d20ef5dfe1bb\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1200/1*6rFVWLu8FXGXfmASVP3zYQ.jpeg\",\n    \"publishDate\": \"2022-06-02T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"3\"\n    ],\n    \"authors\": [\n      \"68\"\n    ]\n  },\n  {\n    \"id\": \"82\",\n    \"title\": \"I/O 22: What's New in Android\",\n    \"content\": \"For a survey of what I/O 22 offers to Android developers in video form check out the What’s New in Android talk.\",\n    \"url\": \"https://youtu.be/Z6iFhczA3NY\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/Z6iFhczA3NY/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"1\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"83\",\n    \"title\": \"I/O 22: What's New for Android Devs\",\n    \"content\": \"Move quickly through some of the top material for Android Devs at IO/22\",\n    \"url\": \"https://www.youtube.com/watch?v=l0iBPh7k_HQ\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/l0iBPh7k_HQ/hqdefault.jpg?sqp=-oaymwEmCOADEOgC8quKqQMa8AEB-AH-CYAC0AWKAgwIABABGGUgUShhMA8=&rs=AOn4CLCNz_S_i8TyDdvX_y1-SZGyAfoK3A\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"1\",\n      \"5\",\n      \"3\",\n      \"8\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"84\",\n    \"title\": \"I/O 22: Android Fireside Chat\",\n    \"content\": \"Android Fireside Chat is back! Android leaders answered your questions from the stage.\",\n    \"url\": \"https://www.youtube.com/watch?v=wq3Et-D9P5Y\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/wq3Et-D9P5Y/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-17T23:00:00.000Z\",\n    \"type\": \"\",\n    \"topics\": [\n      \"1\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"85\",\n    \"title\": \"13 Things to know for Android developers at Google I/O!\",\n    \"content\": \"There aren’t many platforms where you can build something and instantly reach billions of people around the world, not only on their phones—but their TVs, cars, tablets, watches, and more. Today, at Google I/O, we covered a number of ways Android helps you make the most of this opportunity, and how Modern Android Development brings as much commonality as possible, to make it faster and easier for you to create experiences that tailor to all the different screens we use in our daily lives.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/05/13-things-to-know-for-android-developers-at-google-io.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiC2X0sIY_AGvgi6jD8Eh_u8rOdZXKA6PP18tnJdA6jQxR-n4bF6vsIVI2D4FTOnHAlqSY5hJShEjHcRQr7P8QM-YyP3sM3Su_KxFRdBXhg8WUIoXr74luWfFvtgYGJHWdDe_gPnwpCsLR4YhE0U88QcSqrYs3LLjp7dGqQul_pRoerJr__-mD8lUPA/w1200-h630-p-k-no-nu/Android-IO22AndroidDevRecap_Social.png\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"1\",\n      \"3\",\n      \"2\",\n      \"5\",\n      \"19\"\n    ],\n    \"authors\": [\n      \"62\"\n    ]\n  },\n  {\n    \"id\": \"86\",\n    \"title\": \"I/O 22: Now in Android, the App ⏱️\",\n    \"content\": \"After being available on this blog, our YouTube series, and a podcast, starting today, you can check out the alpha version of the Now in Android app on GitHub that was featured in the Google I/O 2022 Developer Keynote 🎉\\n\\nThe app showcases best practices, opinionated designs, and solutions to complex real-world problems. \",\n    \"url\": \"https://android-developers.googleblog.com/2022/05/now-in-android-sample-app-alpha.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgCfJQnd9fqg_J5d_j4lWDbJQ-u-sHd_Z_z8srVPoEuO3_CWY3eVZBulaRTPxqQV3VNkA_1qqkleLVYBI7tRtSIZsOsIDzOKKstOLehI8a1RAUwFgHpXY-3kEmoEPujjQZU1VUk08DesedqDdiA1ZOxUxR-XJIMb66G3gruUq3cxqHwokSQSWycIRPl/w1200-h630-p-k-no-nu/Now-in-Android%28Splash%29.jpg\",\n    \"publishDate\": \"2022-05-11T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"3\",\n      \"2\"\n    ],\n    \"authors\": [\n      \"42\"\n    ]\n  },\n  {\n    \"id\": \"87\",\n    \"title\": \"I/O 22: Jetpack, Compose, and Tooling 🚀\",\n    \"content\": \"What’s new in Jetpack covered additions and updates to the over 120 libraries we’ve created to address common pain points and make development easier.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/05/whats-new-in-jetpack-compose.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiQyCrAWdIb8-moiYuP7EdpznRLOLaKoZWJ04MLzMi1wkJrMfLKQshwXhB_ODNz3T6_aoOwQ0YccVpSbLO2K9qkpx-HTklvNm3ZR_spOINLr861_PgDXDnh6LgpptIyzR5Nv-UjlQ-5FyeLpHwOCb4NjZ8darLIomTVjHM2VvDv7YZdzO-FS6zMKEhlCQ/w1200-h630-p-k-no-nu/Android-JetpackCompose1.2-Social.png\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"3\",\n      \"8\"\n    ],\n    \"authors\": [\n      \"65\",\n      \"52\"\n    ]\n  },\n  {\n    \"id\": \"88\",\n    \"title\": \"I/O 22: Lazy layouts in Compose\",\n    \"content\": \"Compose brings a simple and performant way of creating scrolling lists, with fewer lines of code than RecyclerView. Learn how lazy layouts enable adding content on demand, how to use Lazy composables, how these work under the hood, and optimization tips for better performance.\",\n    \"url\": \"https://youtu.be/1ANt65eoNhQ\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/1ANt65eoNhQ/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"3\",\n      \"5\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"89\",\n    \"title\": \"I/O 22: Fragments: The good (non-deprecated) parts\",\n    \"content\": \"Fragments have been in constant motion over the past couple of years as we move towards a world where all the behavior is defined. Some of these moves have resulted in new APIs designed to do a specific function and replace old, unreliable ones. Learn about the changes in menus, fragment communication, the new strict mode, single lifecycle, and more.\",\n    \"url\": \"https://www.youtube.com/watch?v=OE-tDh3d1F4\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/OE-tDh3d1F4/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"3\",\n      \"5\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"90\",\n    \"title\": \"I/O 22: Performance best practices for Jetpack Compose\",\n    \"content\": \"Jetpack Compose can feel like magic, but what do you do when the magic isn't as performant as you want? Discover best practices in Jetpack Compose with regards to performance, common mistakes, and how to avoid them.\",\n    \"url\": \"https://www.youtube.com/watch?v=EOQB8PTLkpY\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/EOQB8PTLkpY/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"3\",\n      \"7\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"91\",\n    \"title\": \"I/O 22: Workshop: Basic layouts in Compose\",\n    \"content\": \"Learn how to implement a realistic and complex UI using Compose Layouts.\",\n    \"url\": \"https://www.youtube.com/watch?v=kyH01Lg4G1E\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/kyH01Lg4G1E/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"3\",\n      \"2\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"92\",\n    \"title\": \"I/O 22: Workshop: State in Jetpack Compose\",\n    \"content\": \"Discover the core concepts of using state in Jetpack Compose by building a wellness app. Learn how the app's state determines what is displayed in the UI, how Compose keeps the UI updated when state changes, how to optimize the structure of your composable functions, and work with ViewModels in a Compose app.\",\n    \"url\": \"https://www.youtube.com/watch?v=PMMY23F0CFg\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/PMMY23F0CFg/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"3\",\n      \"2\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"93\",\n    \"title\": \"I/O 22: Airbnb uses Jetpack Compose to empower devs to do their best work\",\n    \"content\": \"Jetpack Compose, Android’s modern UI-building toolkit, directly supports both of Airbnb’s development principles. Compose provided a solid foundation for adaptable, quality engineering and reduced boilerplate code, so developers could focus on delivering a great user experience — and advance their two-fold pursuit of engineering excellence.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/05/airbnb-uses-jetpack-compose.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEj1-9FyHvhui7rTgRvUNvyQE8Mmrx5vQ1ZHnuYs0DMdkwFeBK7DuGIP2VL1sgxiQgBtrGvKF4j0QcKInGgSWXCx4bszhPM4VwmuUHm1VIBjmkJqBSWPYk4D9fPmDVhK3asVTNqmkxRjTidzZpzaUzQYn0JmQzjwblhp3el20qowfr00yTpPdKyhefcT/w1200-h630-p-k-no-nu/image1.gif\",\n    \"publishDate\": \"2022-05-11T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"3\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"94\",\n    \"title\": \"I/O 22: What’s new in Android development tools \",\n    \"content\": \"Get an overview of what's new in Android Studio for Android app developers, including demos and a presentation of relevant features that can accelerate developers' workflow on the latest Android APIs.\",\n    \"url\": \"https://www.youtube.com/watch?v=RFv8GkLd5OY\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/RFv8GkLd5OY/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"5\",\n      \"2\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"95\",\n    \"title\": \"I/O 22: Designing apps for large screens\",\n    \"content\": \"Explore key concepts and strategies for adapting mobile apps to large screen devices, such as tablets and foldables. Dig into the challenges of optimizing design and finding ways to meet the changing expectations of your users, in order to offer the highest quality large screen app experience.\",\n    \"url\": \"https://www.youtube.com/watch?v=pvdqeIM6mh0&t\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/pvdqeIM6mh0/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"96\",\n    \"title\": \"I/O 22: Learn how to update your app for the larger screen\",\n    \"content\": \"You already have an app you’ve been working on for years, with a set, and hard to change, architecture. Discover the many options to bring your existing app forward, and build optimized large screen experiences without starting from scratch.\",\n    \"url\": \"https://www.youtube.com/watch?v=1ZOQ_-XPSv8\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/1ZOQ_-XPSv8/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"97\",\n    \"title\": \"I/O 22: Implementing Android apps for all screen sizes \",\n    \"content\": \"Get a better understanding of how to build your app to fill all screen formats. Explore development best practices to optimize applications for all devices with an emphasis on Jetpack Compose, navigation, managing state, and testing.\",\n    \"url\": \"https://www.youtube.com/watch?v=MPwf5DklKp0&t\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/MPwf5DklKp0/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"98\",\n    \"title\": \"I/O 22: Input for all screens\",\n    \"content\": \"Learn about some straight-forward best practices to support input methods like keyboard, mouse, and stylus. These simple changes can elevate your app experience and grow user engagement.\",\n    \"url\": \"https://www.youtube.com/watch?v=XtImpP23uhE\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/XtImpP23uhE/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"99\",\n    \"title\": \"I/O 22: Building an adaptive layout with SlidingPaneLayout\",\n    \"content\": \"Learn how to add a list and detail layout to a View-based app with SlidingPaneLayout. Explore how to reconfigure your fragments, open and close the detail pane, handle custom back navigation, and disable gesture navigation.\",\n    \"url\": \"https://www.youtube.com/watch?v=2rtLdF9UFqg\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/2rtLdF9UFqg/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"100\",\n    \"title\": \"I/O 22: Android Developer Story: eBay gets a 4.7 Google Play rating\",\n    \"content\": \"Matthew Mossman, Android engineer for eBay, shares how he and his team optimized the eBay app for large screens and discusses the impact they saw in their customer’s engagement and experience on the tablets.\",\n    \"url\": \"https://www.youtube.com/watch?v=8gGXwOxHQjk\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/8gGXwOxHQjk/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"101\",\n    \"title\": \"I/O 22: Tablet moments, built by you!\",\n    \"content\": \"Android developers around the world are building some amazing experiences for tablets and large screen devices. You can see how Facebook, TikTok, HBO Max, Zoom and Google Slides are all enhancing their applications.\",\n    \"url\": \"https://www.youtube.com/watch?v=IRiEcVfJJko\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/IRiEcVfJJko/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"102\",\n    \"title\": \"I/O 22: Second Beta of Android 13\",\n    \"content\": \"The Android 13 Beta is available to test on a range of devices from Asus, Lenovo, Nokia, OnePlus, Oppo, Realme, Sharp, TECNO, Vivo, Xiaomi, and ZTE.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/05/second-beta-of-android-13.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjjV9RykVMNPi7wNhkdMXSTn14sT_GE3-0m7iHPi6zfEIMlLjUr9I8icC7vKh7u0bTKqpU6PKnMKGufNHfE7QJJjvwU6PcTygM0Umd0sEh3C1lYKkAxyeJfOCJblem10kjPCZWlwpUT6E-ITy1F3AglIvqQAoA6mxcHCUAmjNzXfNXw2lCOLUQvjTHj/w1200-h630-p-k-no-nu/Android13_dpp.png\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"DAC - Android version features\",\n    \"topics\": [\n      \"13\"\n    ],\n    \"authors\": [\n      \"14\"\n    ]\n  },\n  {\n    \"id\": \"103\",\n    \"title\": \"I/O 22: Developing privacy user-centric apps\",\n    \"content\": \"Keeping users in control of their privacy and safeguarding everything they do online is more important than ever, particularly when it comes to the mobile operating system.\",\n    \"url\": \"https://www.youtube.com/watch?v=opGkUl8C-HM&t\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/opGkUl8C-HM/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"13\",\n      \"11\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"104\",\n    \"title\": \"I/O 22: Building the Privacy Sandbox\",\n    \"content\": \"The Privacy Sandbox initiative is an industry-wide effort to make Android and the web private. It introduces a set of privacy-preserving APIs that give both developers and entrepreneurs the tools they need to build thriving digital businesses and protect people's privacy online. Hear from a panel of Privacy Sandbox team members to hear answers to popular questions.\",\n    \"url\": \"https://www.youtube.com/watch?v=NKz5oT6kXI4&t\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/NKz5oT6kXI4/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"13\",\n      \"11\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"105\",\n    \"title\": \"I/O 22: Overview of the Privacy Sandbox on Android\",\n    \"content\": \"The Privacy Sandbox on Android is intended to fundamentally advance privacy for the ecosystem, without sacrificing key ads functionality and without putting access to free ad-supported apps at risk. Listen to an overview of the key changes and technical considerations for developers.\",\n    \"url\": \"https://www.youtube.com/watch?v=pQdzFbmlvOo\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/pQdzFbmlvOo/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"13\",\n      \"11\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"106\",\n    \"title\": \"I/O 22: Best practices for running background work on Android\",\n    \"content\": \"Learn about several changes to how and when apps can run tasks in the background. Discover why the changes were made and some best practices for developers to run work in the background.\",\n    \"url\": \"https://www.youtube.com/watch?v=t1_8WSEguDY\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/t1_8WSEguDY/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"13\",\n      \"7\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"107\",\n    \"title\": \"I/O 22: What’s new in Android machine learning\",\n    \"content\": \"Learn about the latest APIs and early access programs for machine learning (ML) on Android.\",\n    \"url\": \"https://www.youtube.com/watch?v=tG6hiQNMLmE\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/tG6hiQNMLmE/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"13\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"108\",\n    \"title\": \"I/O 22: What’s new in Android Camera\",\n    \"content\": \"A camera is one of the top reasons consumers purchase phones and devices. Explore the latest in Android Camera and upcoming launches. Learn best practices when using Android Camera and how to bring delightful experiences to users.\",\n    \"url\": \"https://www.youtube.com/watch?v=n8mubjwEVxQ\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/n8mubjwEVxQ/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"18\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"109\",\n    \"title\": \"I/O 22: What’s new in Android media\",\n    \"content\": \"Get a high level overview of everything that's new in Android media, including media features and changes in Android 12+, as well as new features in ExoPlayer and the Jetpack media libraries. Hear about key announcements in Spatial audio, HDR video, video transcoding, editing APIs, AV1 decode, and Performance Class 13.\",\n    \"url\": \"https://www.youtube.com/watch?v=Ba70zmFZgk0\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/Ba70zmFZgk0/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"18\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"110\",\n    \"title\": \"I/O 22: Workshop: How to optimize media streaming with ExoPlayer\\n\",\n    \"content\": \"This workshop guides you through using the new Jetpack Media3 APIs to build a simple media app using ExoPlayer for progressive and adaptive streaming playback. Learn about the advantages of using ExoPlayer and the features it offers.\",\n    \"url\": \"https://www.youtube.com/watch?v=Hw0Jeq42FNU\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/Hw0Jeq42FNU/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"18\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"111\",\n    \"title\": \"I/O 22: What’s new in Accessibility for developers\\n\",\n    \"content\": \"Making applications accessible ensures equal access to roughly one billion people in the world with disabilities, and it can also benefit people without disabilities by providing a better user experience in general. Learn about new developments in Android Studio, a new API that improves the video consuming experience, and advancements in Jetpack Compose that can help you build more accessible apps.\",\n    \"url\": \"https://www.youtube.com/watch?v=6LsaP6oKxMY\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/6LsaP6oKxMY/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"14\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"112\",\n    \"title\": \"I/O 22: What's new in app performance\",\n    \"content\": \"Users expect apps to launch quickly and scroll smoothly, even on low-end devices. That's why performance enhancements should be available on devices with older versions of Android. Learn how to write, maintain, and monitor apps that perform to users' high standards.\",\n    \"url\": \"https://www.youtube.com/watch?v=DYdHLqLVspY\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/DYdHLqLVspY/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"7\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"113\",\n    \"title\": \"I/O 22: Introducing Google Wallet and developer API features\",\n    \"content\": \"Learn more about how to use the Google Wallet APIs to digitize tickets, loyalty cards, and much more. Discover the new Android SDK and additional developer tools to simplify your integration.\",\n    \"url\": \"https://www.youtube.com/watch?v=2gTCghy-dU4\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/2gTCghy-dU4/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"8\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"114\",\n    \"title\": \"I/O 22: Android solutions for seamless sign-in across devices\",\n    \"content\": \"Discover Android solutions that enable seamless and secure login experiences so users can enjoy your app across devices.\",\n    \"url\": \"https://www.youtube.com/watch?v=xghjqgj4peA\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/xghjqgj4peA/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"13\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"115\",\n    \"title\": \"I/O 22: Build power, multi-device experiences\\n\",\n    \"content\": \"Developers play a critical role in Google's multi-device ecosystem. Hear about new tools available to you so that you can build your own powerful, multi-device experiences that span platforms and form factors. Learn about Cast, BlockStore, and the new abstraction layer D2DI.\",\n    \"url\": \"https://www.youtube.com/watch?v=H6UxTnghkMw\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/H6UxTnghkMw/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"13\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"116\",\n    \"title\": \"I/O 22: Announcing Compose for Wear OS Beta!\",\n    \"content\": \"Today we’re launching the Beta release of Compose for Wear OS, our modern declarative UI toolkit designed to help developers create beautiful user experiences for Wear OS.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/05/announcing-compose-for-wear-os-beta.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhrfM5DyOkb4yfuiuJEOelmS5x4sTioxYCRPdnoSI1h64j-xWkWq9Wk0mZ61ljUw_tkO7NXxKsDUb5TbYMHLxLxhcY24rzNnnDhjzFOOClkM_WH--2bTLJFq93HODS7PHebGl00oluu0Sg5p0MTeNAfusLgHvorSxTDS26YwnQXkTJsDq2HJC36m7Jl/w1200-h630-p-k-no-nu/image4.png\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"19\",\n      \"3\",\n      \"8\",\n      \"13\"\n    ],\n    \"authors\": [\n      \"75\"\n    ]\n  },\n  {\n    \"id\": \"117\",\n    \"title\": \"I/O 22: Introducing Health Connect\",\n    \"content\": \"Introducint Health Connect, a new API for Android app developers to securely access user health dataAs Android developers, connecting and sharing health and fitness data between apps can help you provide more meaningful experiences and insights for your users\",\n    \"url\": \"https://android-developers.googleblog.com/2022/05/introducing-health-connect.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEioB9TK2fLuYAv8nIzhQMGsmSQJFhcWTsEl4ZUkR1XXbEkmR4JR3ZBP2N3YLYU143Lo02Qsx3iXE2VBobBBDJ0fr9V_2_epxOtnDLRA9S2XpkUdAWO-OyBejhkrf53wv4sIqnaqmjRB8iu8XzeFhCgM00gxgln1M-ipVeww9WG5bduNTBcpxRpASMha/w1200-h630-p-k-no-nu/231491533__37260715__148018.png\",\n    \"publishDate\": \"2022-05-11T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"8\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"118\",\n    \"title\": \"I/O 22: What’s new with Google TV & Android TV OS\",\n    \"content\": \"Since last year’s I/O, we’ve continued our commitment to enable you to build better and more engaging experiences on Android TV OS. In addition to platform updates, new features, like expanded integrations with the Live tab, offer opportunities for users to better engage with your content.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/05/whats-new-with-google-tv-android-tv-os.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgkqwL9j8jLb9ItKoISX9Yo0d3r_IdPdNMje6cca3obZpFDuNmmfKsL6Qj-E3agkDK7E312kuVjVLw3Ez2dF-vVj9UeRSUUPuOuc2T3T9d-HqEaZ6g76NQXEvqwIik0rAqFRZPiE6NH2PfgFmaDDQh6hb81HRgQrzmWGT6WJyuyK-yxnrrCfV4YHYnh/w1200-h630-p-k-no-nu/Android_WhatsNewInAndroidTVGoogleTV_1024x512.png\",\n    \"publishDate\": \"2022-05-11T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"16\",\n      \"13\"\n    ],\n    \"authors\": [\n      \"27\"\n    ]\n  },\n  {\n    \"id\": \"119\",\n    \"title\": \"I/O 22: What’s new with Android for Cars\",\n    \"content\": \"We’re excited to share some of our combined accomplishments from this past year, and introduce new updates that will make it easier for you to provide users with an even better experience in the car.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/05/whats-new-with-android-for-cars.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgNnogZQP4IGFjoSw7QWt1vr7MphwOi1OtRAAfCAVaNDTdnD5_CAaKhsf11nzxS_XrQ3ERet5yhpK7bs0e5YXdarv6o8iuzNYRqJ25fZrRL8TPfyBGpADg3oOrGM364foSvEdNhSTqDOF_2_TTBkRq-rElETpNAC6pOIHioH7ug3s8z8iJ_f3vWL5pTuQ/w1200-h630-p-k-no-nu/Android-whats-new-with-android-for-cars-io22-social%20%281%29.png\",\n    \"publishDate\": \"2022-05-11T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"15\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"120\",\n    \"title\": \"I/O 22: What's new in Google Play\",\n    \"content\": \"At this year’s Google I/O, we focused on three major ways we can help you continue growing your business on Google Play: Privacy and security initiatives ; Tools to help you improve your app quality; New ways to help you acquire users and engage with existing ones\",\n    \"url\": \"https://android-developers.googleblog.com/2022/05/whats-new-in-google-play.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiugYmi_1-WFvfxVe8BJ5GCiZjAp1R_B42dvrxu-fHkL1WkswlvjZpAVImVJIJdgzEERdFyzF9QzNZYPmoAJDEe2lfwdOnpSr2zHiQy0od18YP2ozVpC_fR7WQUpeB9kJyqTLx7udivLZn1w3trWfVeT4ejl8e9bZqVJfUyH05k0SgODQpYsUb4Junn/w1200-h630-p-k-no-nu/Play-new-google-play-io-01.png\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"12\",\n      \"8\",\n      \"11\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"121\",\n    \"title\": \"I/O 22: New Google Play SDK Index\",\n    \"content\": \"New Google Play SDK Index helps you choose the right SDKs for your app. Helping developers, like you, make informed decisions about SDKs is part of keeping Google Play a safe, trusted space for billions of people.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/05/new-google-play-sdk-index.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjEBzXvqydpodashPiKchgLE9NA6WXVUYbTFBuooFn8_XwK6E4cMEbM7hyiTRPZ-H3pwTvyHY8143pGB5zgUt_zgUuzsjAIkRSQsngYBd4_dusLSXF94j6KZ0r1UiZC3vQFrabOw9vXdA0Wzcm3SDm_LvlCFdxW67-FplcMJLpZYLQ02I2EUrvORXHl/w1200-h630-p-k-no-nu/Play-new-google-play-sdk-index-social-V2.png\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"12\"\n    ],\n    \"authors\": [\n      \"87\"\n    ]\n  },\n  {\n    \"id\": \"122\",\n    \"title\": \"I/O 22: How to integrate Android widgets with Google Assistant\",\n    \"content\": \"Explore intermediate-level concepts for integrating Android widgets with Google Assistant. Provide quick answers and interactive app experiences on Assistant enabled surfaces like Android and Android Auto to their users.\",\n    \"url\": \"https://www.youtube.com/watch?v=6vXZcg9g_Mo\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/6vXZcg9g_Mo/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"123\",\n    \"title\": \"I/O 22: Integrate Google Assistant into Android for cars\",\n    \"content\": \"Learn how to integrate voice into apps built for Android Auto. Learn to add voice to Widgets for Auto, and explore some of the other voice-first features coming to the platform.\",\n    \"url\": \"https://www.youtube.com/watch?v=hhdVMLG5Y10\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/hhdVMLG5Y10/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"2\",\n      \"15\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"124\",\n    \"title\": \"I/O 22: ADB Podcast Episode 185 : Play Store🎙\",\n    \"content\": \"In this episode Tor, Chet, and Romain chat with Jon and Andrew from the Play team about the Play Store app, which recently went through a major refactoring.\",\n    \"url\": \"https://adbackstage.libsyn.com/episode-185-play-store\",\n    \"headerImageUrl\": \"http://assets.libsyn.com/show/332855?height=250&width=250&overlay=true\",\n    \"publishDate\": \"2022-05-17T23:00:00.000Z\",\n    \"type\": \"Podcast 🎙\",\n    \"topics\": [\n      \"12\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"125\",\n    \"title\": \"Android Basics with Compose\",\n    \"content\": \"We released the first two units of Android Basics with Compose, our first free course that teaches Android Development with Jetpack Compose to anyone; you do not need any prior programming experience other than basic computer literacy to get started. \",\n    \"url\": \"https://android-developers.googleblog.com/2022/05/new-android-basics-with-compose-course.html\",\n    \"headerImageUrl\": \"https://developer.android.com/images/hero-assets/android-basics-compose.svg\",\n    \"publishDate\": \"2022-05-04T23:00:00.000Z\",\n    \"type\": \"Codelab\",\n    \"topics\": [\n      \"2\",\n      \"3\",\n      \"10\"\n    ],\n    \"authors\": [\n      \"25\"\n    ]\n  },\n  {\n    \"id\": \"126\",\n    \"title\": \"Android 13 Beta 1\",\n    \"content\": \"Beta 1 includes the latest updates to photo picker, themed app icons, improved localization and language support, and the new notification permission which requires apps targeting Android 13 to request the notification permission from the user before posting notifications. Check out the beta by visiting the Android 13 developer site.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/04/android-13-beta-1-blog.html\",\n    \"headerImageUrl\": \"https://developer.android.com/about/versions/13/images/android-13-hero_1440.png\",\n    \"publishDate\": \"2022-05-04T23:00:00.000Z\",\n    \"type\": \"DAC - Android version features\",\n    \"topics\": [\n      \"13\"\n    ],\n    \"authors\": [\n      \"14\"\n    ]\n  },\n  {\n    \"id\": \"127\",\n    \"title\": \"Architecture: Entities - MAD Skills\",\n    \"content\": \"In this episode, Garima from GoDaddy Studio talks about entities and more specifically how creating separate entities per layer per project leads to clean and scalable model architecture.\",\n    \"url\": \"https://www.youtube.com/watch?v=cfak1jDKM_4\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/cfak1jDKM_4/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-04T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"4\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"128\",\n    \"title\": \"Architecture: Live Q&A - MAD Skills\",\n    \"content\": \"Manuel , Yigit , TJ , and Milosz hosted a very special Architecture Q&A and answered questions from the community. Find out the answers to: “Is LiveData deprecated?”, “MVVM or MVI for Compose”, “Should we use flow in DataSources” and more.\",\n    \"url\": \"https://www.youtube.com/watch?v=_2BtE1P6MPE\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/_2BtE1P6MPE/maxresdefault.jpg\",\n    \"publishDate\": \"2022-05-04T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"4\"\n    ],\n    \"authors\": [\n      \"23\",\n      \"34\",\n      \"38\"\n    ]\n  },\n  {\n    \"id\": \"129\",\n    \"title\": \"MAD Skills: Architecture\",\n    \"content\": \"To wrap up the Architecture Android MAD skills series, \\nManuel wrote a blog post summarizing each episode of the series! Check it out to get caught up on all things Android Architecture.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/04/architecture-mad-skills-series-wrap-up.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjwpLUhIDR7IVIgPnCayAMbm0cYAC0ktSWLT9_vWJ1au0oZK_0un_IlXfu4HixEtc4G_AOi4BkWATw6BsyFCTPtCiu2wSvnfL3OVqWVNdblp6neIuFh9N3KH02SYDBgr6hIpAU7A9KjX9mT3oFJI6uuasaYqqMg_GZgptg0aooIqLywmeTp_PrpTAOj/s1600/1_J2NKRQ4qedvMVWoxL_4ZLA.jpeg\",\n    \"publishDate\": \"2022-05-04T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"4\"\n    ],\n    \"authors\": [\n      \"23\"\n    ]\n  },\n  {\n    \"id\": \"130\",\n    \"title\": \"The first developer preview of Privacy Sandbox on Android\",\n    \"content\": \"Privacy Sandbox is a program to help you conduct initial testing of the proposed APIs and evaluate how you might adopt them for your solutions. The Developer Preview provides additional platform APIs and services on top of the Android 13 Developer Beta release, SDK, system images, Preview APIs, API reference, and support references. See the release notes for more details on what’s included in the release.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/04/first-preview-privacy-sandbox-android.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEimeEtKqNjaaRY2oxecefVbcytzULjln30fNMxfJQfWOu6Tqy9XYQKAVkwLTeGRiPh50RBIxyA6HD86_Qm_Vpiit7eEO1ZmeZttgdsH187-cL8YE-w6NOvqYDwcn-MzIPmk0yiJy-4_kbsZ4_k3CngfP36F-U5g-PQmunzFpPAHuWtBNCsHcbP80flJ/s1600/Android_PrivacySandboxonAndroidDeveloper_4209x1253.png\",\n    \"publishDate\": \"2022-05-04T23:00:00.000Z\",\n    \"type\": \"DAC - Android version features\",\n    \"topics\": [\n      \"11\",\n      \"13\"\n    ],\n    \"authors\": [\n      \"77\"\n    ]\n  },\n  {\n    \"id\": \"131\",\n    \"title\": \"Expanding Play’s Target Level API Requirements 🎯\",\n    \"content\": \"Starting on November 1, 2022, apps that don’t target an API level within two years of the latest major Android release version will not be available on Google Play to new users with devices running Android OS versions newer than your app’s target API level. For example, as of this November, existing apps need to target at least API level 30, Android 11, to be available to new users on Android 12 and 13 devices.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/04/expanding-plays-target-level-api-requirements-to-strengthen-user-security.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEifh6osWctzfS76FGmd91DArGexlVFw7BNh0ZCqgSuU5aO1AU2pt2T554nkpGy8AzeY_oIOY-TWc0YsS_DwMR9yp3aV_TSrgh7-XPNAg8jSDe_8ySG4ae6D6OqVUMzPmwEoPDXvEhA09um5qahSO1cfSjWIk03bq7vUVDvDHnvt-EubXLKw_Dz2uoUI/s1600/Android-New-policy-update-to-strengthen-Google-Play-social.png\",\n    \"publishDate\": \"2022-04-13T23:00:00.000Z\",\n    \"type\": \"DAC - Android version features\",\n    \"topics\": [\n      \"12\"\n    ],\n    \"authors\": [\n      \"10\"\n    ]\n  },\n  {\n    \"id\": \"132\",\n    \"title\": \"Google Play PolicyBytes - April 2022 policy updates\",\n    \"content\": \"Users who have previously installed your app from Google Play will continue to be able to discover, re-install, and use your app, even if they move to a new Android device. App updates still also need to target an API level within a year of the latest major Android release version. Expanding our target level API requirements will protect users from installing older apps that may not have the privacy and security protections in place that newer Android releases offer.\",\n    \"url\": \"https://www.youtube.com/watch?v=O0UwUF2DgQc\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/O0UwUF2DgQc/maxresdefault.jpg\",\n    \"publishDate\": \"2022-04-13T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"12\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"133\",\n    \"title\": \"Upgrading Android Attestation: Remote Provisioning 🔐\",\n    \"content\": \"Attestation for device integrity has been mandated since Android 8.0, and is used in a variety of services such as SafetyNet. Android 12 added the option of Remote Key Provisioning for device manufacturers, and it will be mandated in Android 13. If you’re leveraging attestation in your app, watch out for a longer certificate chain structure, a new root of trust, the deprecation of RSA attestation, and short-lived certificates/attestation keys.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/03/upgrading-android-attestation-remote.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhXGH6tNkY1UkXgIQluciMoaSR9hZNKAoKcRyv_UxyHbEuPRvTVfWT4A_3BQEb_HCMUALR5bScXZsIEzHiRJwrFgm9fhouknFkP5H5ngCUtdf7uiGpTuCOm5dF5rtDrjR5Vm0r9NNU4J7lzN3k0sdMQumgan-NPp2nPSgXypTqj-yqn6BBS9URGrh1F/s1600/Android-KeyAttestation-Header.png\",\n    \"publishDate\": \"2022-04-13T23:00:00.000Z\",\n    \"type\": \"DAC - Android version features\",\n    \"topics\": [\n      \"13\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"134\",\n    \"title\": \"Architecture: Handling UI events - MAD Skills\",\n    \"content\": \"With this episode of MAD skills we continue with our architecture series of videos. In this video you'll learn about UI events. Developer Relations Engineer Manuel Vivo covers the different types of UI events, the best practices for handling them, and more!\",\n    \"url\": \"https://www.youtube.com/watch?v=lwGtp0Yr0PE\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/lwGtp0Yr0PE/maxresdefault.jpg\",\n    \"publishDate\": \"2022-04-13T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"4\"\n    ],\n    \"authors\": [\n      \"23\"\n    ]\n  },\n  {\n    \"id\": \"135\",\n    \"title\": \"Architecture: The Domain Layer - MAD Skills\",\n    \"content\": \"In this episode of MAD skills you'll learn about the Domain Layer - an optional layer which sits between the UI and Data layers. Developer Relations Engineer Don Turner will explain how the domain layer can simplify your app architecture, making it easier to understand and test.\",\n    \"url\": \"https://www.youtube.com/watch?v=gIhjCh3U88I\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/gIhjCh3U88I/maxresdefault.jpg\",\n    \"publishDate\": \"2022-04-13T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"4\"\n    ],\n    \"authors\": [\n      \"42\"\n    ]\n  },\n  {\n    \"id\": \"136\",\n    \"title\": \"Architecture: Organizing modules - MAD Skills\",\n    \"content\": \"In this episode of Architecture for Modern Android Development Skills, Emily Kager shares a tip around organizing modules in Android apps.\",\n    \"url\": \"https://www.youtube.com/watch?v=HB_r9wn49Gc\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/HB_r9wn49Gc/maxresdefault.jpg\",\n    \"publishDate\": \"2022-04-13T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"4\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"137\",\n    \"title\": \"App Excellence: Android Architecture\",\n    \"content\": \"How do you build a successful app? Our advice: think like a building architect. If you need help getting started, we have the perfect blueprint for success when building on Android. Check out our updated guide to Android App Architecture, and build something that your users will love.\",\n    \"url\": \"https://www.youtube.com/watch?v=fodD6UHjLmw\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/fodD6UHjLmw/maxresdefault.jpg\",\n    \"publishDate\": \"2022-04-13T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"4\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"138\",\n    \"title\": \"Accessibility on TV - Integrate with Android TV & Google TV\",\n    \"content\": \"Thinking about accessibility is critical when developing a quality app on Android TV OS. Ian will be covering the most common issues, the solution to these issues, and some more complex scenarios. \",\n    \"url\": \"https://www.youtube.com/watch?v=GyglHvJ6LMY\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/GyglHvJ6LMY/maxresdefault.jpg\",\n    \"publishDate\": \"2022-04-13T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"16\",\n      \"14\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"139\",\n    \"title\": \"Google Play Billing - Integrate with Android TV & Google TV\",\n    \"content\": \"In this episode of Integrate with Android TV & Google TV, Thomas will discuss how you can monetize your Android TV app using Google Play Billing. \",\n    \"url\": \"https://www.youtube.com/watch?v=gb55CjH7NHY\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/gb55CjH7NHY/maxresdefault.jpg\",\n    \"publishDate\": \"2022-04-13T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"16\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"140\",\n    \"title\": \"Android for Cars 🚗\",\n    \"content\": \"Android for cars has introduced media recommendations powered by Google Assistant, a progress bar for long form content, and per-item content styles to allow browsable/playable items to be individually assigned to a list or grid. Head on over to the developer documentation to learn about all of these changes.\",\n    \"url\": \"https://developer.android.com/cars\",\n    \"headerImageUrl\": \"https://developers.google.com/cars/design/images/designforcars_1920.png\",\n    \"publishDate\": \"2022-04-13T23:00:00.000Z\",\n    \"type\": \"Docs 📑\",\n    \"topics\": [\n      \"16\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"141\",\n    \"title\": \"Google Play Academy🎓 - Go Global: Japan\\n\",\n    \"content\": \"With over 2 billion active Android devices globally, more and more developers are looking for new markets to expand. If you’re looking to succeed in Japan, one of the largest mobile app and gaming markets, these courses will cover strategies to make your content relevant across development, marketing and growth, and monetization.\",\n    \"url\": \"https://www.youtube.com/watch?v=hY1HH-9efkg\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/hY1HH-9efkg/maxresdefault.jpg\",\n    \"publishDate\": \"2022-04-13T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"12\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"142\",\n    \"title\": \"Google Play Academy🎓 - Go Global: Southeast Asia\",\n    \"content\": \"With over 2 billion active Android devices globally, more and more developers are looking for new markets to expand. If you’re looking to succeed in Southeast Asia, a fast-growing market that spends as much as 1.5x more time on the mobile internet than any other region, these courses will cover strategies to make your content relevant across development, marketing and growth, and monetization.\",\n    \"url\": \"https://www.youtube.com/watch?v=j9VRzvDhTO0\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/j9VRzvDhTO0/maxresdefault.jpg\",\n    \"publishDate\": \"2022-04-13T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"12\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"143\",\n    \"title\": \"Google Play Academy🎓 - Design for All Users\",\n    \"content\": \"Learn how to optimize for onboarding, build accessible apps, and reduce app size to reach more users.\",\n    \"url\": \"https://www.youtube.com/watch?v=07NUULjEJ5A\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/07NUULjEJ5A/maxresdefault.jpg\",\n    \"publishDate\": \"2022-04-13T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"12\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"144\",\n    \"title\": \"Game development 🎮\",\n    \"content\": \"We covered how to help you monitor your game’s stability using Android vitals on Google Play Console, how to best optimize your game to improve your customer engagement during the month of Ramadan, and announced that the Indie Games Accelerator & Indie Games Festival 2022 from Google Play is coming soon, offering a way to get notified when submissions open.\",\n    \"url\": \"https://www.youtube.com/watch?v=m2gTnT2kCRQ\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/m2gTnT2kCRQ/maxresdefault.jpg\",\n    \"publishDate\": \"2022-04-13T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"17\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"145\",\n    \"title\": \"Migrating Architecture Blueprints to Jetpack Compose\",\n    \"content\": \"Manuel wrote about how and why we’ve Migrated our Architecture Blueprints to Jetpack Compose, and some issues we faced in doing so.\",\n    \"url\": \"https://medium.com/androiddevelopers/migrating-architecture-blueprints-to-jetpack-compose-8ffa6615ede3\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/1*J2NKRQ4qedvMVWoxL_4ZLA.jpeg\",\n    \"publishDate\": \"2022-04-13T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"4\",\n      \"3\"\n    ],\n    \"authors\": [\n      \"23\"\n    ]\n  },\n  {\n    \"id\": \"146\",\n    \"title\": \"The curious case of FLAG_ACTIVITY_LAUNCH_ADJACENT\",\n    \"content\": \"Pietro wrote about how to enable split screen use cases using the Android 7.0 FLAG_ACTIVITY_LAUNCH_ADJACENT flag to open your Activity in a new adjacent window on Android 12L. (and some supported Android 11+ devices)\",\n    \"url\": \"https://medium.com/androiddevelopers/the-curious-case-of-flag-activity-launch-adjacent-f1212f37a8e0\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/1*YWg6uZkqSakAb5vW6uc-gg.png\",\n    \"publishDate\": \"2022-04-13T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": [\n      \"12\"\n    ]\n  },\n  {\n    \"id\": \"147\",\n    \"title\": \"AndroidX releases 🚀\",\n    \"content\": \"AppCompat AppCompat-Resources Version 1.5.0-alpha01 contains a bunch of bugfixes, as well as updated nullability to match Android 13 DP2 and a few small compatibility features involving TextView, AppCompatDialog, SearchView, and SwitchCompat.\\n\\nNavigation Version 2.4.2 has been released with all the new bugfixes backported from the 2.5 alpha releases.\",\n    \"url\": \"https://developer.android.com/jetpack/androidx/versions/all-channel\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/a/AVvXsEhxtjouMCgk8sv8wkvC5Aip4muxMo4TLnfSVtZ3Hw7ZpuqXQmk-EkV9qk9PKim0yVFVFlpjEJG-vqh6gCLFkQPuf2dQk6qEdQZM_9brvuxBA0dtOUlvUh7tMIQsF11RnSnSPWOPKDIzeiixfapL2ax4YgMahJppgG_a5rjs_4QBjzzgzqsDs9Wc-Ldx=w1200-h630-p-k-no-nu\",\n    \"publishDate\": \"2022-04-13T23:00:00.000Z\",\n    \"type\": \"Jetpack release 🚀\",\n    \"topics\": [\n      \"8\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"148\",\n    \"title\": \"ADB Podcast Episodes🎙184: Skia and AGSL - Shaders of Things to Come\",\n    \"content\": \"In this episode Tor, Chet, and Romain chat with Derek and Brian from the Skia team about Skia (the graphics layer that backs the Android Canvas APIs), pixel shaders, and the new “AGSL” API that lets you provide pixel shaders for advanced graphics effects, which was recently added as part of the Android T preview release. If you’re interested in graphics technology, this is the episode for you.\",\n    \"url\": \"https://adbackstage.libsyn.com/episode-184-skia-and-agsl-shaders-of-things-to-come\",\n    \"headerImageUrl\": \"https://ssl-static.libsyn.com/p/assets/9/4/d/b/94dbe077f2f14ee640be95ea3302a6a1/ADB184_Skia_and_AGSL.png\",\n    \"publishDate\": \"2022-04-13T23:00:00.000Z\",\n    \"type\": \"Podcast 🎙\",\n    \"topics\": [\n      \"2\",\n      \"8\"\n    ],\n    \"authors\": [\n      \"32\",\n      \"30\",\n      \"31\"\n    ]\n  },\n  {\n    \"id\": \"149\",\n    \"title\": \"Android 13 DP 2 😍\",\n    \"content\": \"Recently we shared Android 13 Developer Preview 2 with more new features and changes for you to try in your apps! Some notable features include Developer downgradable permissions which allows your app to protect user privacy by downgrading previously granted runtime permissions, and Bluetooth LE Audio which helps users receive high fidelity audio without sacrificing battery life; it can also seamlessly switch between different use cases that were not possible with Bluetooth Classic. Check out all the new features in the post!\",\n    \"url\": \"https://android-developers.googleblog.com/2022/03/second-preview-android-13.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/a/AVvXsEjnrShXcFkBmErmhgdmx82vJbaKBIxU6p2Yz2Vr1V7AlFkD2tGwRmx_a7tWcInPmiUh8VpPmEEqXut-EjP23lFYG9wiMO4sKBDEwbZ3MNppZOy_HW54OXO4SkdQVH08cWdi7QnTMMwGELFoPq_r7_cyaGU8fx2InJG2R-NfkqF1IRt7rKOfA8M1GhUy\",\n    \"publishDate\": \"2022-03-29T23:00:00.000Z\",\n    \"type\": \"DAC - Android version features\",\n    \"topics\": [\n      \"13\"\n    ],\n    \"authors\": [\n      \"14\"\n    ]\n  },\n  {\n    \"id\": \"150\",\n    \"title\": \"Architecture: The data layer - MAD Skills\",\n    \"content\": \"Jose goes over the data layer and its two components: repositories and data sources. You will dive deeper into what the roles of these two are and understand their differences. You will also learn about data immutability, error handling, threading testing and more!\",\n    \"url\": \"https://www.youtube.com/watch?v=r5AseKQh2ZE\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/r5AseKQh2ZE/maxresdefault.jpg\",\n    \"publishDate\": \"2022-03-29T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"4\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"151\",\n    \"title\": \"Architecture: The UI layer - MAD Skills\",\n    \"content\": \"TJ covers the UI layer in this episode of MAD skills using the JetNews sample app as a case study You will learn UI Layer pipeline, UI state exposure, UI state consumption and more!\",\n    \"url\": \"https://www.youtube.com/watch?v=p9VR8KbmzEE\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/p9VR8KbmzEE/maxresdefault.jpg\",\n    \"publishDate\": \"2022-03-29T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"4\"\n    ],\n    \"authors\": [\n      \"38\"\n    ]\n  },\n  {\n    \"id\": \"152\",\n    \"title\": \"Account Linking - Integrate with Android TV & Google TV\",\n    \"content\": \"Miguel and Adekunle discuss account linking. ​​Google Account Linking lets you safely link a user’s Google Account with their account on your platform, granting Google applications and devices access to your services and is needed for many integrations on Android TV & Google TV. They discuss the basics of OAuth like implementing your authorization, token exchange, and revocation endpoints. You will also learn the difference between the Web OAuth, Streamlined, and App Flip linking flows.\",\n    \"url\": \"https://www.youtube.com/watch?v=-Fa99hpUsdk\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/-Fa99hpUsdk/maxresdefault.jpg\",\n    \"publishDate\": \"2022-03-29T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"16\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"153\",\n    \"title\": \"Modern media playback on Android - Integrate with Android TV & Google TV\",\n    \"content\": \"Paul explores best practices for integrating and validating media sessions, the unified way for Android apps to interact with media content. MediaSessions allows different devices like smart speakers, watches, peripherals and accessories to surface and interact with playback and content metadata.\",\n    \"url\": \"https://www.youtube.com/watch?v=OYy41ceW59s\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/OYy41ceW59s/maxresdefault.jpg\",\n    \"publishDate\": \"2022-03-29T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"16\"\n    ],\n    \"authors\": [\n      \"27\"\n    ]\n  },\n  {\n    \"id\": \"154\",\n    \"title\": \"FHIR SDK for Android Developers 🏥\",\n    \"content\": \"Community health workers in low-and-middle-income countries use mobile devices as critical tools for doing community outreach and providing crucial health services. Unfortunately, the lack of data interoperability means that patient records are fragmented and caregivers may only receive incomplete information. Last year, Google introduced a collaboration with the World Health Organization to build an open source software developer kit designed to help developers build mobile solutions using the Fast Healthcare Interoperability Resources (FHIR) global standard for healthcare data. Read the article to learn more about how this SDK can help you create apps to aid community health workers in low-and-middle-income countries.\",\n    \"url\": \"https://medium.com/androiddevelopers/our-fhir-sdk-for-android-developers-9f8455e0b42f\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/1*azSHuKma0fz1RxcPcqiusg.png\",\n    \"publishDate\": \"2022-03-29T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"1\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"155\",\n    \"title\": \"Helping Users Discover Quality Apps on Large Screens 🔎\",\n    \"content\": \"Adoption of large screens is growing rapidly and now there are over 250M active Android tablets, foldables, and ChromeOS devices. To help people get the most from their devices, we’re making three big changes in Google Play to enable users to discover and engage with high quality apps and games: ranking and promotability changes, alerts for low quality apps, and device-specific ratings and reviews. Read all about it in the post!\",\n    \"url\": \"https://android-developers.googleblog.com/2022/03/helping-users-discover-quality-apps-on.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/a/AVvXsEiWXMaly6_CP_gSHmxE92yVBXUQ1X5EcTA6pdKwo_NsAtO1Ouv_RhHxG1HqtbStufdnylV51VbHI0FmmPV8lvmLAOqNzhcD2znU3vWVajQXfOlFw_kP-01jxSvrzVIXZG7SCQMiw58yUaWgmqzO-dsaso5DOeYVKnwQm3Vdu9lFmogfCkQT5u7H0sVt\",\n    \"publishDate\": \"2022-03-29T23:00:00.000Z\",\n    \"type\": \"Docs 📑\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": [\n      \"61\"\n    ]\n  },\n  {\n    \"id\": \"156\",\n    \"title\": \"Access Android vitals data through the new Play Developer Reporting API\",\n    \"content\": \"In this article Lauren talks about Android vitals which are a great way to track how your app is performing in Google Play Console. Now there are new use cases for Android vitals which include building internal dashboards, joining with other datasets for deeper analysis and automation troubleshooting and releases. Learn more about the API and how to use it in this post!\",\n    \"url\": \"https://android-developers.googleblog.com/2022/03/play-developer-reporting-API.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/a/AVvXsEhnvMF36lJv9wDDHWLQb7AfVBajueyEuocw_9ne1jgKJAO5dgXWcAyrKa92f4miTcFoSH5usz_Jha2C1gJwJNSr6et8sZGSCnkZTgtdaKPemEfwaHJDjiurWaPtqFF3qI0aX7aRB7B9WUW1VXT_Wgkyyq8nYK7RrOy9zW4a7gROkzd3H5m9T36Bc7Ww\",\n    \"publishDate\": \"2022-03-29T23:00:00.000Z\",\n    \"type\": \"Docs 📑\",\n    \"topics\": [\n      \"12\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"157\",\n    \"title\": \"Using performance class to optimize your user experience\",\n    \"content\": \"The Jetpack Core Performance library in alpha has launched! This library enables you to easily identify what a device is capable of, and tailor your user experience accordingly. As developer, this means you can reliably group devices with the same level of performance and tailor your app’s behavior to those different groups. This enables you to deliver an optimal experience to users with both more and less capable devices.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/03/using-performance-class-to-optimize.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjYa28aPEBLCSzLkir02bVSWusH5BBIiAcq_CzCx5DD3iQu5WyDLC1sZe1y5OInomon5KHJKemMCa5q6XAtfkMhljMoePuebLGDz6yRDU3cjkwMo7sV5WKe20KNzWhP1ktdOn7OxOxeiXqzeDrPwLcpoVaStm8840phqHOqDptiQ0twMsGTD2u3o0Xf/s1600/Android-using-performance-class-to-optimize-user-experience-header%20%281%29.png\",\n    \"publishDate\": \"2022-03-29T23:00:00.000Z\",\n    \"type\": \"Docs 📑\",\n    \"topics\": [\n      \"7\"\n    ],\n    \"authors\": [\n      \"42\"\n    ]\n  },\n  {\n    \"id\": \"158\",\n    \"title\": \"AndroidX releases 🚀\",\n    \"content\": \"We have a few libraries in alpha-01 including Activity Version 1.6.0-alpha01, CustomView-Poolingcontainer Version 1.0.0-alpha01, and Junit-Gtest Version 1.0.0-alpha01.\\n\\nCar App Version 1.2.0-rc01 and Mediarouter Version 1.3.0-rc01 are also in rc-01.\",\n    \"url\": \"https://developer.android.com/jetpack/androidx/versions/all-channel\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/a/AVvXsEi6y_NUo9gnpHYdRc7lwnbVnraBtUSIZTnIoAcHXkbq8Z0AFHBUHDI_s7HwwP2h2nTwo571RnRuXN-sUWdgJ7qkNb2MSslYiXWP3tteXooTdwAS_YzbZMTux25eLZk0kgdLtXmWTRLdolft-ZcsgGjCyJnH-CjzHsZXGy8vNVxB5oFZkBExOpBwvoDL=w1200-h630-p-k-no-nu\",\n    \"publishDate\": \"2022-03-29T23:00:00.000Z\",\n    \"type\": \"Jetpack release 🚀\",\n    \"topics\": [\n      \"8\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"159\",\n    \"title\": \"Discontinuing Kotlin synthetics for views\",\n    \"content\": \"Synthetic properties to access views were created as a way to eliminate the common boilerplate of findViewById calls. These synthetics are provided by JetBrains in the Kotlin Android Extensions Gradle plugin (not to be confused with Android KTX).\",\n    \"url\": \"https://android-developers.googleblog.com/2022/02/discontinuing-kotlin-synthetics-for-views.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/a/AVvXsEhAba1fyHq6ddgUfT09YxU3XkAhnolKyLnXE2GmcJABVT-y8PWLKUiC7LmesY7Txak65fc6nW8T7yar9_4Uz4ezcBA_MDZ-yqBR2cj4ipSN-5l_E57exa3m9qt2MHFo_RdLWc_YDX7J7AOMkyzs43ylbGtwl6Z8GSf1zgs71Te36cQ-9Z_qgMgFroLq=w1200-h630-p-k-no-nu\",\n    \"publishDate\": \"2022-02-18T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"2\",\n      \"10\"\n    ],\n    \"authors\": [\n      \"1\"\n    ]\n  },\n  {\n    \"id\": \"160\",\n    \"title\": \"Things to know from the 2022 Google for Games Developer Summit\",\n    \"content\": \"This week marked the 2022 Google for Games Developer Summit, Google’s biggest event of the year centered around game development. The Android team shared information around the next generation of services, tools and features to help you develop and deliver high quality games. \",\n    \"url\": \"https://android-developers.googleblog.com/2022/03/GGDS-recap-blog.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/a/AVvXsEhW4RL-UKUurgM2bVJRepqjKehVETjf9bqdXllyspPaWTTt8s86MGvfxlxLkDyJAnnkGr7vDpDTPx6bQbgkThYXMSaW1GQvXw9V57xybA8Y89vIE45JDElGxSNFHwOAndATPYrGmc200fkyBTRSNi7w53hTbS1ao-TSoEBFs8jvTgz6ud5Tcb1qitkt\",\n    \"publishDate\": \"2022-03-15T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"17\"\n    ],\n    \"authors\": [\n      \"2\"\n    ]\n  },\n  {\n    \"id\": \"161\",\n    \"title\": \"MAD Skills: DataStore and Introduction to Architecture💡\",\n    \"content\": \"Now that our MAD Skills series on Jetpack DataStore is complete, let’s do a quick wrap up of all the things we’ve covered in each episode.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/03/jetpack-datastore-wrap-up.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/a/AVvXsEgo2-I1LhMjWd1zzpIQXzjMCPoZeUZc35n43UosKDuLMyP7rIDe8cGfs23tmkSAed6Wxw9EoNTIpvvWCljermK_lCu0etlrCnONx3WeXMCGe-s8I45hYhuVo6w_Q2UTNATMTA70t2o9MS5p2pBdPFz5Ye4b2ajOJjNlW9rELtqWcEW4O1Rkzy4lfqRO\",\n    \"publishDate\": \"2022-03-14T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"9\"\n    ],\n    \"authors\": [\n      \"3\"\n    ]\n  },\n  {\n    \"id\": \"162\",\n    \"title\": \"Play Time with Jetpack Compose\",\n    \"content\": \"Learn about Google Play Store’s strategy for adopting Jetpack Compose, how they overcame specific performance challenges, and improved developer productivity and happiness.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/03/play-time-with-jetpack-compose.html\",\n    \"headerImageUrl\": \"https://developer.android.com/images/social/android-developers.png\",\n    \"publishDate\": \"2022-03-10T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"3\",\n      \"12\"\n    ],\n    \"authors\": [\n      \"4\",\n      \"5\"\n    ]\n  },\n  {\n    \"id\": \"163\",\n    \"title\": \"App Excellence Summit 2022 ⭐\",\n    \"content\": \"Did you know that 54% of users who left a 1-star review in the Play Store mentioned app stability and bugs? *\\n\\nTo help product managers and business decision makers understand how high quality app experiences drive business growth and what tools they can use to make sound business and technical decisions, we are hosting our first Android App Excellence Summit in just a few weeks!\",\n    \"url\": \"https://android-developers.googleblog.com/2022/03/app-excellence-summit-2022.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/a/AVvXsEh4Vck7mqle-tLweEgrIc1WT0ycY6O6zBxv9mC1Dt1xCnJN5COTGFxDSQlIM1rbbMKIMZHPtjzXgENMGk80oxb5Mn8kTn6qO7kgUXC_N5YSB0dWxcXvQOIPHEEgNJze9g8eZrY1xgA9_oBls71NLItDJKTYeoJGEXxIBiAE_c6SkXv2jSELZEoFfqVq\",\n    \"publishDate\": \"2022-03-10T00:00:00.000Z\",\n    \"type\": \"Event 📆\",\n    \"topics\": [\n      \"1\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"164\",\n    \"title\": \"#TheAndroidShow: Tablets, Jetpack Compose, and Android 13 📹\",\n    \"content\": \"Last week, Florina and Huyen hosted #TheAndroidShow, where we went Behind the scenes with animations & Jetpack Compose, asked whether now is the moment to think tablet first, and covered Android 13 along with other key themes for Android this year.\",\n    \"url\": \"https://www.youtube.com/watch?v=WL9h46CymlU\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/WL9h46CymlU/maxresdefault.jpg\",\n    \"publishDate\": \"2022-03-09T00:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"3\",\n      \"2\",\n      \"13\",\n      \"1\"\n    ],\n    \"authors\": [\n      \"6\"\n    ]\n  },\n  {\n    \"id\": \"165\",\n    \"title\": \"Freeing up 60% of storage for apps 💾\",\n    \"content\": \"App archiving will allow users to reclaim ~60% of app storage temporarily by removing parts of the app rather than uninstalling the app completely.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/03/freeing-up-60-of-storage-for-apps.html\",\n    \"headerImageUrl\": \"https://developer.android.com/images/social/android-developers.png\",\n    \"publishDate\": \"2022-03-08T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"9\"\n    ],\n    \"authors\": [\n      \"7\",\n      \"8\"\n    ]\n  },\n  {\n    \"id\": \"166\",\n    \"title\": \"Demystifying Jetpack Glance for app widgets\",\n    \"content\": \"We recently announced the first Alpha version of Glance, initially with support for AppWidgets and now for Tiles for Wear OS. This new framework is built on top of the Jetpack Compose runtime and designed to make it faster and easier to build “glanceables” such as app widgets without having to handle a lot of boilerplate code or lifecycle events to connect different components.\",\n    \"url\": \"https://medium.com/androiddevelopers/demystifying-jetpack-glance-for-app-widgets-8fbc7041955c\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/1*mlswR3fyxaIG-C1OUifYVw.jpeg\",\n    \"publishDate\": \"2022-03-07T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"2\",\n      \"19\"\n    ],\n    \"authors\": [\n      \"9\"\n    ]\n  },\n  {\n    \"id\": \"167\",\n    \"title\": \"Keeping Google Play safe with our key 2022 initiatives 🔒\",\n    \"content\": \"We shared information about what’s ahead in 2022 for Google Play’s privacy and safety initiatives to give you time to prepare.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/03/privacy-and-security-direction.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/a/AVvXsEhh3FMLL-etD7iDzhSI6CoYbuwgB9ZADjXa6A9C4aM3W-eRqj1FGfP8dyMY4i5RlMtQJD8Sx1y1NHFuaCae10iZkAs_cETaCAllzCDU075awpkAc1pkhld7uxwjTmwNdihGhB-FtySiSsf9aknd1ZULz0zkRtybX4gRUp8JCbPh2n3pPEhjK0mTjNWS\",\n    \"publishDate\": \"2022-03-03T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"11\"\n    ],\n    \"authors\": [\n      \"10\"\n    ]\n  },\n  {\n    \"id\": \"168\",\n    \"title\": \"Games-Activity Version 1.1.0\",\n    \"content\": \"adds WindowInsets listening/querying for notch and IME response along with key and motion event filters.\",\n    \"url\": \"https://developer.android.com/jetpack/androidx/releases/games#1.1.0\",\n    \"headerImageUrl\": \"https://developer.android.com/images/social/android-developers.png\",\n    \"publishDate\": \"2022-02-23T00:00:00.000Z\",\n    \"type\": \"Jetpack release 🚀\",\n    \"topics\": [\n      \"17\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"169\",\n    \"title\": \"Room Version 2.5.0-alpha01\",\n    \"content\": \"Converted room-common, room-migration, and paging related files in room-runtime from Java to Kotlin along with a new API for multi-process lock to protect multi-process 1st time database creation and migrations\",\n    \"url\": \"https://developer.android.com/jetpack/androidx/releases/room#2.5.0-alpha01\",\n    \"headerImageUrl\": \"https://developer.android.com/images/social/android-developers.png\",\n    \"publishDate\": \"2022-02-23T00:00:00.000Z\",\n    \"type\": \"Jetpack release 🚀\",\n    \"topics\": [\n      \"9\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"170\",\n    \"title\": \"Media Version 1.6.0-alpha 01\",\n    \"content\": \"Adds the extras necessary to setup a signin/settings page using CarAppLibrary.\",\n    \"url\": \"https://developer.android.com/jetpack/androidx/releases/media#media-1.6.0-alpha01\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/a/AVvXsEjo_CL5arSn2zb_YKP8hKjaMG3nqXXPQ_zn05X9FQ0XLdE2Ii6WeGG0eD_miObCRv2iz3hJ2T0lIIn3iDFyT3yN8B7NFET_fE5nhcw6MHQmOKK4G4R5XgXTkEIyqY4kjz2F5hpPscvQgsz0aRkVqSLynp-6x9HqkoldNYwDSp7kbttmh2JCW1cwUXhG=w1200-h630-p-k-no-nu\",\n    \"publishDate\": \"2022-02-23T00:00:00.000Z\",\n    \"type\": \"Jetpack release 🚀\",\n    \"topics\": [\n      \"18\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"171\",\n    \"title\": \"AppCompat-Resources Version 1.6.0-alpha01\",\n    \"content\": \"Adds support for customizing locales, providing backwards compatibility for the Android 13 per-language preferences API\",\n    \"url\": \"https://developer.android.com/jetpack/androidx/releases/appcompat#1.6.0-alpha01\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/a/AVvXsEhxsx2mynFgTjtKCAxJiWvIYuJF1sNFfRCPnEbBWSmQLATP_Z6Bmz81sr9WmS2CWVUqIzW4uYyRyW2wQSLR73i9WXLUzGc-LbMS-QEcQQZI5qoymfRf3pyrMnOeGuFAKsfLaAEtquvNyqA2KaO28BnF3plt0jr6kVYIyl0tkpWhxHa47CPuNvhEehQ1=w1200-h630-p-k-no-nu\",\n    \"publishDate\": \"2022-02-23T00:00:00.000Z\",\n    \"type\": \"Jetpack release 🚀\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"172\",\n    \"title\": \"Recording Video with CameraX VideoCapture API\",\n    \"content\": \"A picture is worth a thousand words, and CameraX ImageCapture has already made it much easier to tell your story through still images on Android. Now with the new VideoCapture API, CameraX can help you create thousands of continuous pictures to tell an even better and more engaging story!\",\n    \"url\": \"https://medium.com/androiddevelopers/recording-video-with-camerax-videocapture-api-a36cfd8a48c8\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/1*GZmhCFMCrG4L_mOtwSb0zA.png\",\n    \"publishDate\": \"2022-02-23T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"18\"\n    ],\n    \"authors\": [\n      \"11\"\n    ]\n  },\n  {\n    \"id\": \"173\",\n    \"title\": \"Unbundling the stable WindowManager\",\n    \"content\": \"The 1.0.0 stable release of Jetpack WindowManager, the foundation for great experiences on all types of large screen devices.\",\n    \"url\": \"https://medium.com/androiddevelopers/unbundling-the-stable-windowmanager-a5471ff2907\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/0*dIXjHF8_-47CvYTb.png\",\n    \"publishDate\": \"2022-02-17T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": [\n      \"12\"\n    ]\n  },\n  {\n    \"id\": \"174\",\n    \"title\": \"Jetpack Compose 1.1 is now stable!\",\n    \"content\": \"Last week we released version 1.1 of Jetpack Compose and Florina Muntenescu wrote an article giving us all the information! This release contains new features like improved focus handling, touch target sizing, ImageVector caching and support for Android 12 stretch overscroll. This also means that previously experimental APIs are now stable. Check out our recently updated samples, codelabs, and the Accompanist library!\",\n    \"url\": \"https://android-developers.googleblog.com/2022/02/jetpack-compose-11-now-stable.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/a/AVvXsEiEIiQOoFF-f-sDcbYOMINZw5-2R9aQjrREfiXFMGsRYODVfaz1sgdCS2C3UjgeJjCII5oyE4y97kbvQIUsl9wIx8RqTSZPSdIoCywW89lvmAJ5a15bkFOwoR9UacCEUb4CjOMy0omVMfC0CQhUfz9VMTZR4iyjDGagEZfNuMid8BT0lvarns9Tp6PC\",\n    \"publishDate\": \"2022-02-09T00:00:00.000Z\",\n    \"type\": \"Jetpack release 🚀\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": [\n      \"6\"\n    ]\n  },\n  {\n    \"id\": \"175\",\n    \"title\": \"MAD Skills: DataStore\",\n    \"content\": \"The DataStore MAD Skills series rolls on! In the sixth episode, Simona Stojanovic covered DataStore: Best Practices part 2 covering DataStore-to-DataStore migration. This is used when you make significant changes to your dataset like renaming your data model values or changing their type. \",\n    \"url\": \"https://medium.com/androiddevelopers/datastore-and-data-migration-fdca806eb1aa\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/0*8wsdb7Z7QxT1d4lM\",\n    \"publishDate\": \"2022-02-15T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"9\"\n    ],\n    \"authors\": [\n      \"3\"\n    ]\n  },\n  {\n    \"id\": \"176\",\n    \"title\": \"DataStore and Testing\",\n    \"content\": \"For the final part of the DataStore series, Simona covered DataStore and testing and teaches you how to fully test your DataStore.\",\n    \"url\": \"https://medium.com/androiddevelopers/datastore-and-testing-edf7ae8df3d8\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/1*5_yt1M6_QEMN0OgGU8VaZw.png\",\n    \"publishDate\": \"2022-02-16T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"9\"\n    ],\n    \"authors\": [\n      \"3\"\n    ]\n  },\n  {\n    \"id\": \"177\",\n    \"title\": \"Material You: Coming to more Android Devices near you\",\n    \"content\": \"Material You will soon be available on more Android 12 phones globally including devices by Samsung, Oppo, OnePlus and more! Material You has made the Android experience more fluid and personal than ever. Our OEM partners continue to work with us to ensure that key design APIs work consistently across the Android ecosystem so developers can benefit from a cohesive experience.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/02/material-you-coming-to-more-android.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/a/AVvXsEhDOIPFoqZ8uvg7VmH5EuY3ocfxvKZXawUQ9NczUCEtOdpw3v42vSTrpUSvHjbph5KmTlDH-XtnmGeXmCFTMaHDnRS9ibzLUHBip_XnVHUL7xv-3UrVL6plimErj_oK_KyW5ULpmj6orVTaTq9r56K0V3npQFdIrBPE7_caRWb_QA5E9FljpREWVB7Y\",\n    \"publishDate\": \"2022-02-10T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"2\",\n      \"13\"\n    ],\n    \"authors\": [\n      \"13\"\n    ]\n  },\n  {\n    \"id\": \"178\",\n    \"title\": \"The first developer preview of Android 13\",\n    \"content\": \"We’re sharing a first look at the next release of Android, with the Android 13 Developer Preview 1. With Android 13 we’re continuing some important themes: privacy and security, as well as developer productivity. We’ll also build on some of the newer updates we made in 12L to help you take advantage of the 250+ million large screen Android devices currently running.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/02/first-preview-android-13.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/a/AVvXsEjnrShXcFkBmErmhgdmx82vJbaKBIxU6p2Yz2Vr1V7AlFkD2tGwRmx_a7tWcInPmiUh8VpPmEEqXut-EjP23lFYG9wiMO4sKBDEwbZ3MNppZOy_HW54OXO4SkdQVH08cWdi7QnTMMwGELFoPq_r7_cyaGU8fx2InJG2R-NfkqF1IRt7rKOfA8M1GhUy\",\n    \"publishDate\": \"2022-02-10T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"13\"\n    ],\n    \"authors\": [\n      \"14\"\n    ]\n  },\n  {\n    \"id\": \"179\",\n    \"title\": \"AndroidX releases 🚀\",\n    \"content\": \"Since Compose just went stable, the Animation, Compiler, Foundation, Material, Runtime and UI Versions also went stable! Games-Text-Input and ProfileInstaller also went stable! \\n\\nThere are a bunch of new APIs in alpha including new Testing APIs (Test Runner, Test Monitor, Test Services and Test Orchestrator), Metrics Version and Startup Version.\",\n    \"url\": \"https://developer.android.com/jetpack/androidx/versions/all-channel#february_9_2022\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/0*bux1xKYcB3A9pBFx\",\n    \"publishDate\": \"2022-02-09T00:00:00.000Z\",\n    \"type\": \"Docs 📑\",\n    \"topics\": [\n      \"3\",\n      \"8\"\n    ],\n    \"authors\": [\n      \"15\"\n    ]\n  },\n  {\n    \"id\": \"180\",\n    \"title\": \"DataStore best practices part 1\",\n    \"content\": \"learn about performing synchronous work and how to make it work with Kotlin data class serialization and Hilt.\",\n    \"url\": \"https://www.youtube.com/watch?v=S10ci36lBJ4\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/S10ci36lBJ4/maxresdefault.jpg\",\n    \"publishDate\": \"2022-02-07T00:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"9\"\n    ],\n    \"authors\": [\n      \"3\"\n    ]\n  },\n  {\n    \"id\": \"181\",\n    \"title\": \"All about Proto DataStore\",\n    \"content\": \"In this post, we will learn about Proto DataStore, one of two DataStore implementations. We will discuss how to create it, read and write data and how to handle exceptions, to better understand the scenarios that make Proto a great choice.\",\n    \"url\": \"https://medium.com/androiddevelopers/all-about-proto-datastore-1b1af6cd2879\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/1*UtNu7pmbt3WEA213SW9p9Q.png\",\n    \"publishDate\": \"2022-01-31T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"9\"\n    ],\n    \"authors\": [\n      \"3\"\n    ]\n  },\n  {\n    \"id\": \"182\",\n    \"title\": \"Glance: Tiles for Wear OS made simple ⌚️\",\n    \"content\": \"Last year we announced the Wear Tiles API. To complement that Java API, we are excited to announce that support for Wear OS Tiles has been added to Glance, a new framework built on top of Jetpack Compose designed to make it easier to build for surfaces outside your Android app. As this library is in alpha, we’d love to get your feedback.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/01/announcing-glance-tiles-for-wear-os.html\",\n    \"headerImageUrl\": \"https://developer.android.com/images/social/android-developers.png\",\n    \"publishDate\": \"2022-01-26T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"19\"\n    ],\n    \"authors\": [\n      \"16\"\n    ]\n  },\n  {\n    \"id\": \"183\",\n    \"title\": \"Android Studio Bumblebee 🐝 stable\",\n    \"content\": \"Android Studio Bumblebee (2021.1.1) is now stable. We’ve since patched it to address some launch issues — so make sure to upgrade! It improves functionality across the typical developer workflow: Build and Deploy, Profiling and Inspection, and Design.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/01/android-studio-bumblebee-202111-stable.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/a/AVvXsEhQ7R2ySipHb8y5jNJeiIj3pE8dZfWAV7EF0wQZ4rQ65lB4MsZroAT4R_7rSfznMZ30xBMLx9_dwnt05V6I0Du0EfI7mvLicK6LwdkuZsF_Gc3sPqrZGxkojTJpHCXFI3Kvr3bLyoSjElldtt1NUpGSBzHgG3O1pvS9BR02L9R2_FYTUgPLfUoNLWYQ\",\n    \"publishDate\": \"2022-01-25T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"5\"\n    ],\n    \"authors\": [\n      \"17\"\n    ]\n  },\n  {\n    \"id\": \"184\",\n    \"title\": \"All about Preferences DataStore\",\n    \"content\": \"In this post, we will take a look at Preferences DataStore, one of two DataStore implementations. We will go over how to create it, read and write data, and how to handle exceptions, all of which should, hopefully, provide you with enough information to decide if it’s the right choice for your app.\",\n    \"url\": \"https://medium.com/androiddevelopers/all-about-preferences-datastore-cc7995679334\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/1*UtNu7pmbt3WEA213SW9p9Q.png\",\n    \"publishDate\": \"2022-01-24T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"9\"\n    ],\n    \"authors\": [\n      \"3\"\n    ]\n  },\n  {\n    \"id\": \"185\",\n    \"title\": \"Building apps for Android Automotive OS 🚘\",\n    \"content\": \"The Car App Library version 1.2 is already in beta, enabling app developers to start building their navigation, parking, and charging apps for Android Automotive OS. Now, developers can begin building and testing apps for these categories using the Automotive OS emulator across both Android Automotive OS and Android Auto.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/01/building-apps-for-android-automotive-os.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/a/AVvXsEicCVDoRaflBAdKr9_Zh2cAGUB8pphAj9m0w1iN7VLizNZ6L2iNGNSnt7tvD6MP72BW8eqobZpU751t32aF47bpNDv2walZ6zzsXxyuAjCyhBl0b4o06X1j3bPi0AAU0EedqYjp5FSXMQHHzvxBedjsST3MIIFvalX3tZpgiFZgEdqbB2f_H741Irrb=w1200-h630-p-k-no-nu\",\n    \"publishDate\": \"2022-01-27T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"15\"\n    ],\n    \"authors\": [\n      \"18\"\n    ]\n  },\n  {\n    \"id\": \"186\",\n    \"title\": \"Navigation 2.4 is stable \",\n    \"content\": \"It’s been rewritten in Kotlin, with two pane integration, Navigation routes + Kotlin DSL improvements, Navigation Compose’s first stable release, and multiple back stack support.\",\n    \"url\": \"https://developer.android.com/jetpack/androidx/releases/navigation#2.4.0\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/a/AVvXsEhsJgTKhUlIb-1X_G1rWQLCxpd0KMmGTUgqUfSNr4__CsxxjiOdJgJHCtgO9dG8mZdwzAHat9HyIcMsvA-fS0o6T0-_ut_Ej74hKfn09AJUPNc3YscwfGG6hqFS-W_oTczgtd1aGNzpCdDDo4b4lrUM3n8OsFKjvslqE6pHRY3w0aZSTHsaYytSnQSA=w1200-h630-p-k-no-nu\",\n    \"publishDate\": \"2022-01-26T00:00:00.000Z\",\n    \"type\": \"Jetpack release 🚀\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"187\",\n    \"title\": \"Google Maps with Jetpack Compose\",\n    \"content\": \"A project which contains Jetpack Compose components for the Google Maps SDK for Android.\\n\\n\",\n    \"url\": \"https://github.com/googlemaps/android-maps-compose\",\n    \"headerImageUrl\": \"https://opengraph.githubassets.com/0952eadfbb07f5ce9f631fd0312d87e8f0e2557df01bac3b587311ca864cf836/googlemaps/android-maps-compose\",\n    \"publishDate\": \"2022-02-11T00:00:00.000Z\",\n    \"type\": \"Jetpack release 🚀\",\n    \"topics\": [\n      \"3\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"188\",\n    \"title\": \"Improving App Performance with Baseline Profiles\",\n    \"content\": \"In this blog post we’ll discuss Baseline Profiles and how they improve app and library performance, including startup time by up to 40%. While this blogpost focuses on startup, baseline profiles also significantly improve jank as well.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/01/improving-app-performance-with-baseline.html\",\n    \"headerImageUrl\": \"https://developer.android.com/images/social/android-developers.png\",\n    \"publishDate\": \"2022-01-28T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"7\"\n    ],\n    \"authors\": [\n      \"19\",\n      \"20\",\n      \"21\"\n    ]\n  },\n  {\n    \"id\": \"189\",\n    \"title\": \"Smule Adopts Google’s Oboe to Improve Recording Quality & Completion Rates\",\n    \"content\": \"As the most downloaded singing app of all time, Smule Inc. has been investing on Android to improve the overall audio quality and, more specifically, to reduce latency, i.e. allowing singers to hear their voices in the headset as they perform. The teams specialized in Audio and Video allocated a significant part of 2021 into making the necessary changes to convert the Smule application used by over ten million Android users from using the OpenSL audio API to the Oboe audio library, enabling roughly a 10%+ increase in recording completion rate.\",\n    \"url\": \"https://android-developers.googleblog.com/2022/02/smule-adopts-googles-oboe-to-improve.html\",\n    \"headerImageUrl\": \"https://developer.android.com/images/social/android-developers.png\",\n    \"publishDate\": \"2022-02-02T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"18\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"190\",\n    \"title\": \"Guide to background work\",\n    \"content\": \"Do you use coroutines or WorkManager for background work? The team updated the guide to background work to help you choose which library is best for your use case. It depends on whether or not the work is persistent, and if it needs to run immediately, it’s long running, or deferrable.\",\n    \"url\": \"https://developer.android.com/guide/background\",\n    \"headerImageUrl\": \"https://developer.android.com/images/social/android-developers.png\",\n    \"publishDate\": \"2022-02-11T00:00:00.000Z\",\n    \"type\": \"Docs 📑\",\n    \"topics\": [\n      \"4\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"191\",\n    \"title\": \"Accessibility best practices\",\n    \"content\": \"If you work on Android TV, you should be aware of the accessibility best practices that the team created. It provides recommendations for both native and non-native apps. Get to know why accessibility is important for your TV app, how to evaluate your apps when TalkBack is used, how to adopt system caption settings, and more!\",\n    \"url\": \"https://developer.android.com/training/tv/accessibility\",\n    \"headerImageUrl\": \"https://developer.android.com/images/social/android-developers.png\",\n    \"publishDate\": \"2022-02-11T00:00:00.000Z\",\n    \"type\": \"Docs 📑\",\n    \"topics\": [\n      \"16\",\n      \"14\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"192\",\n    \"title\": \"TalkBack - the Google screen reader\",\n    \"content\": \"Next up in the Accessibility series is TalkBack, the Google screen reader! In this video, learn what TalkBack is, how to set it up, how to navigate through your app with it, and how you can use it to improve the Accessibility of your app.\",\n    \"url\": \"https://www.youtube.com/watch?v=_1yRVwhEv5I\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/_1yRVwhEv5I/maxresdefault.jpg\",\n    \"publishDate\": \"2022-01-21T00:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"14\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"193\",\n    \"title\": \"ADB Podcast 182: Large screens are a big deal\",\n    \"content\": \"Clara, Florina and Daniel join your usual hosts to talk about large screens, what they are and what they mean for app developers. You will also learn about the resources at your disposal to build high quality experiences on large screen devices: from samples and guidance to canonical layouts and new APIs such as window size classes. Disclaimer: Florina is very excited about this, don’t miss the epic Large screens! Large screens! Large screens! intro!\",\n    \"url\": \"https://adbackstage.libsyn.com/episode-182-large-screens-are-a-big-deal\",\n    \"headerImageUrl\": \"http://assets.libsyn.com/show/332855?height=250&width=250&overlay=true\",\n    \"publishDate\": \"2022-02-01T00:00:00.000Z\",\n    \"type\": \"Podcast 🎙\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"194\",\n    \"title\": \"Jetpack Alpha for Glance Widgets 🔍\",\n    \"content\": \"We made the first release of Jetpack Glance available, a new framework designed to make it faster and easier to build app widgets for the home screen and other surfaces. Glance offers similar modern, declarative Kotlin APIs that you are used to with Jetpack Compose, helping you build beautiful, responsive app widgets with way less code. Glance provides a base-set of its own Composables to help build “glanceable” experiences — starting today with app widget components but with more coming. Using the Jetpack Compose runtime, Glance translates these Composables into RemoteViews that can be displayed in an app widget\",\n    \"url\": \"https://android-developers.googleblog.com/2021/12/announcing-jetpack-glance-alpha-for-app.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/a/AVvXsEgol-A5cMCZY79MH5v0axcekWIVJ--ymPUe0U5Q4BLsC0BA1LTbWIlZ76XWi2cHjxHVu-kbpv0o2QJWBjNAda_93Ah7AW_PcAgz9o082cd6zyTJZAM8HjQnrZ69A6CaKQaCFuf2LLi4p6xRvS_WUn9tVA2K2wmV3_qB6JDKnFNhO3Guvn5tPc_SuoaY\",\n    \"publishDate\": \"2021-12-15T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"8\"\n    ],\n    \"authors\": [\n      \"9\"\n    ]\n  },\n  {\n    \"id\": \"195\",\n    \"title\": \"Jetpack Watch Face Library ⌚\",\n    \"content\": \"We launched the Jetpack Watch Face library written from the ground up in Kotlin, including all functionality from the Wearable Support Library along with many new features such as: Watch face styling which persists across both the watch and phone (with no need for your own database or companion app); Support for a WYSIWYG watch face configuration UI on the phone; Smaller, separate libraries (that only include what you need); Battery improvements through promoting good battery usage patterns out of the box, such as automatically reducing the interactive frame rate when the battery is low; New screenshot APIs so users can see previews of their watch face changes in real time on both the watch and phone.\\n\\nIf you are still using the Wearable Support Library, we strongly encourage migrating to the new Jetpack libraries to take advantage of the new APIs and upcoming features and bug fixes.\",\n    \"url\": \"https://android-developers.googleblog.com/2021/12/develop-watch-faces-with-stable-jetpack.html\",\n    \"headerImageUrl\": \"https://1.bp.blogspot.com/-P4S1eEhqouE/YaaFy_bGD1I/AAAAAAAARNA/-w5O05Mppo8pe0hoeMC1yDNRWiX_mnTOgCLcBGAsYHQ/s0/image1.png\",\n    \"publishDate\": \"2021-12-01T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"19\",\n      \"3\",\n      \"8\"\n    ],\n    \"authors\": [\n      \"22\"\n    ]\n  },\n  {\n    \"id\": \"196\",\n    \"title\": \"Rebuilding our Guide to App Architecture 📐\",\n    \"content\": \"We launched a revamped guide to app architecture which includes best practices. As Android apps grow in size, it’s important to design the code with an architecture in place that allows the app to scale, improves quality and robustness, and makes testing easier. The guide contains pages for UI, domain, and data layers including deep dives into more complex topics, such as how to handle UI events. We also have a learning pathway to walk you through it.\",\n    \"url\": \"https://android-developers.googleblog.com/2021/12/rebuilding-our-guide-to-app-architecture.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/a/AVvXsEgnJ0CCtKClhEOE_BDOoWiXGr2eA6LWjn-RPvFjFx8Va97f_1_xCmpF3uI_bUILoQPqJUDlXUbIRVPjvi3oCiFtRVZlcAAkHBa1cJlufG5OvmeovQeiHgH9bLhxREufi-fw7FnxIcmxGmzWuW0DmYUZolsM6rywTSZIm3KtI6yx9jSIeRpuYzRZubke\",\n    \"publishDate\": \"2021-12-14T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"4\"\n    ],\n    \"authors\": [\n      \"23\"\n    ]\n  },\n  {\n    \"id\": \"197\",\n    \"title\": \"Google Play Games on PC Beta 🎮\",\n    \"content\": \"We announced that we’re opening sign-ups for Google Play Games on PC as a beta in Korea, Taiwan, and Hong Kong, allowing users participating in the beta to play a catalog of Google Play games on their PC via a standalone application built by Google. The developer site has a form to express interest, along with information about bringing your Android game to PCs. It involves many of the same updates that you do to optimize your game for Chrome OS devices, such as support for Mouse and Keyboard controls.\",\n    \"url\": \"https://developers.googleblog.com/2022/01/googleplaygames.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/a/AVvXsEgsNv-PVLNLlX2SYd2p5DwTN2Jxwb54Rc7Ekbm0LgcFuwHBrF_5Y-DiUblL9oTjmeJ1Y44nPRMMkH5K-xlC0OApgUGxqBpUcfuV1LYPVvKsI67BKTpc_gNhaHsNda6Q1Uk1UvTznmMydqNHtXSqTgSJbjpQCoTGZM_ZLXlkGwMoBFfnMQkAIdl2zjsC\",\n    \"publishDate\": \"2022-01-19T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"17\"\n    ],\n    \"authors\": [\n      \"24\"\n    ]\n  },\n  {\n    \"id\": \"198\",\n    \"title\": \"MAD Skills: Gradle 🐘\",\n    \"content\": \"Murat covered building custom plugins in more depth, including the Artifact API in addition to the Variant API covered previously. It demonstrates building a plugin which automatically updates the version code specified in the app manifest with the git version. With the AGP 7.0 release, you can use these APIs to control build inputs, read, modify, or even replace intermediate and final artifacts.\",\n    \"url\": \"https://medium.com/androiddevelopers/gradle-and-agp-build-apis-taking-your-plugin-to-the-next-step-95e7bd1cd4c9\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/0*WkRft2aAKv19MoIm.jpeg\",\n    \"publishDate\": \"2021-12-01T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"5\"\n    ],\n    \"authors\": [\n      \"25\"\n    ]\n  },\n  {\n    \"id\": \"199\",\n    \"title\": \"Gradle and AGP Build APIs: Community tip - MAD Skills\",\n    \"content\": \"On this episode of Gradle and AGP Build APIs for MAD Skills, Alex Saveau walks you through manipulating Android build artifacts with the Android Gradle Plugin (AGP) and Gradle APIs.\",\n    \"url\": \"https://www.youtube.com/watch?v=8SFfffaB0CU\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/8SFfffaB0CU/maxresdefault.jpg\",\n    \"publishDate\": \"2021-12-15T00:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"5\"\n    ],\n    \"authors\": [\n      \"26\"\n    ]\n  },\n  {\n    \"id\": \"200\",\n    \"title\": \"Gradle and AGP Build APIs: Taking your plugin to the next step - MAD Skills\",\n    \"content\": \"On this episode of Gradle and AGP Build APIs for MAD Skills, Murat will discuss Gradle tasks, providers, properties, and basics of task inputs and outputs. Next, you will be able to take your plugin a step further and learn how to get access to various build artifacts using the new Artifact API. \",\n    \"url\": \"https://www.youtube.com/watch?v=SB4QlngQQW0\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/SB4QlngQQW0/maxresdefault.jpg\",\n    \"publishDate\": \"2021-11-29T00:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"5\"\n    ],\n    \"authors\": [\n      \"25\"\n    ]\n  },\n  {\n    \"id\": \"201\",\n    \"title\": \"MAD Skills Gradle and AGP build APIs Wrap Up!\",\n    \"content\": \"This wrap-up post summarizes the whole MAD Skills Gradle series\",\n    \"url\": \"https://android-developers.googleblog.com/2021/12/mad-skills-gradle-and-agp-build-apis.html\",\n    \"headerImageUrl\": \"https://blogger.googleusercontent.com/img/a/AVvXsEgo1Fw61B9qtQESKdVJzcNXOG0RzhA2k85zkDMDNidBiQY7B6uguHXQ9t9IPB9BiHS0WTB1b4fwIgeN5zEIJrmznF9pt5lu9186wvXxJ3IKfLi8Fci8LyMDbQKGYc7nnijJ9_lhrNHtRQamaF2GTSXyJq5_lQk7we3cSfSviOxhgKN9TscMJaGgdMZJ\",\n    \"publishDate\": \"2021-12-16T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"5\"\n    ],\n    \"authors\": [\n      \"25\"\n    ]\n  },\n  {\n    \"id\": \"202\",\n    \"title\": \"MAD Skills: DataStore 🗄️\",\n    \"content\": \"Simona began MAD Skills: DataStore. DataStore is a thread-safe, non-blocking library in Android Jetpack that provides a safe and consistent way to store small amounts of data, such as preferences or application state, replacing SharedPreferences. It provides an implementation that stores typed objects backed by protocol buffers (Proto DataStore) and an implementation that stores key-value pairs (Preferences DataStore).\",\n    \"url\": \"https://www.youtube.com/watch?v=9ws-cJzlJkU\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/9ws-cJzlJkU/maxresdefault.jpg\",\n    \"publishDate\": \"2022-01-18T00:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"9\"\n    ],\n    \"authors\": [\n      \"3\"\n    ]\n  },\n  {\n    \"id\": \"203\",\n    \"title\": \"AndroidX releases 🚀\",\n    \"content\": \"Since the last Now in Android episode, a lot of libraries were promoted to stable! Compose ConstraintLayout brings support for ConstraintLayout syntax to Compose. We also released CoordinatorLayout 1.2, Car App 1.1.0, Room 2.4.0, Sqlite 2.2.0, Collection 1.2.0, and Wear Watchface 1.0.0.\\n\\nOur first alpha of Jetpack Compose 1.2 was released, along with alphas for Glance 1.0.0, Core-Ktx 1.8.0, WorkManager 2.8.0, Mediarouter 1.3.0, Emoji2 1.1.0, Annotation 1.4.0, Core-RemoteViews, Core-Peformance, and more.\",\n    \"url\": \"https://developer.android.com/jetpack/androidx/versions/all-channel#december_1_2021\",\n    \"headerImageUrl\": \"https://developer.android.com/images/social/android-developers.png\",\n    \"publishDate\": \"2021-12-01T00:00:00.000Z\",\n    \"type\": \"Docs 📑\",\n    \"topics\": [\n      \"8\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"204\",\n    \"title\": \"Jetnews for every screen\",\n    \"content\": \"Alex wrote about the recent updates to Jetnews that improves its behavior across big and small mobile devices. It describes our design and development process so that you can learn our philosophy and associated implementation steps for building an application optimized for all screens with Jetpack Compose, including how to build a list/detail layout.\",\n    \"url\": \"https://medium.com/androiddevelopers/jetnews-for-every-screen-4d8e7927752\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/1*678DlYtu4G7wFrq30FQ7Mw.png\",\n    \"publishDate\": \"2022-01-18T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": [\n      \"22\"\n    ]\n  },\n  {\n    \"id\": \"205\",\n    \"title\": \"Simplifying drag and drop\",\n    \"content\": \"Paul wrote about drag & drop, and how the Android Jetpack DragAndDrop library alpha makes it easier to handle data dropped into your app.\",\n    \"url\": \"https://medium.com/androiddevelopers/simplifying-drag-and-drop-3713d6ef526e\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/1*pUe4RBLe7FVlISDtAqeQ4Q.png\",\n    \"publishDate\": \"2021-12-15T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"8\"\n    ],\n    \"authors\": [\n      \"27\"\n    ]\n  },\n  {\n    \"id\": \"206\",\n    \"title\": \"Accessibility series 🌐: Handling content that times out - Accessibility on Android\",\n    \"content\": \"The accessibility series continues on, beginning with an episode on how to properly implement UI elements that disappear after a set amount of time.\",\n    \"url\": \"https://www.youtube.com/watch?v=X97P6Y8WHl0\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/X97P6Y8WHl0/maxresdefault.jpg\",\n    \"publishDate\": \"2021-12-03T00:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"14\"\n    ],\n    \"authors\": [\n      \"28\"\n    ]\n  },\n  {\n    \"id\": \"207\",\n    \"title\": \"Accessibility series 🌐: Acessibility Scanner\",\n    \"content\": \"We also cover how Accessibility Scanner can help you improve your app for all users by suggesting improvements in areas of accessibility.\",\n    \"url\": \"https://www.youtube.com/watch?v=i1gMzQv0hWU\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/i1gMzQv0hWU/maxresdefault.jpg\",\n    \"publishDate\": \"2021-12-10T00:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"14\"\n    ],\n    \"authors\": [\n      \"28\"\n    ]\n  },\n  {\n    \"id\": \"208\",\n    \"title\": \"Accessibility series 🌐: Accessibility test framework and Espresso - Accessibility on Android\",\n    \"content\": \"We investigate how Espresso and the Accessibility Test Framework can help you create automated accessibility tests.\",\n    \"url\": \"https://www.youtube.com/watch?v=DLN2s16HwcE\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/DLN2s16HwcE/maxresdefault.jpg\",\n    \"publishDate\": \"2021-12-22T00:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"14\"\n    ],\n    \"authors\": [\n      \"28\"\n    ]\n  },\n  {\n    \"id\": \"209\",\n    \"title\": \"Android TV & Google TV 📺\",\n    \"content\": \"Mayuri covered best practices for the Watch Next API on Android TV & Google TV, which increases engagement with your app by allowing your content to show up in the Watch Next row.\",\n    \"url\": \"https://www.youtube.com/watch?v=QFMIP5GOo70\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/QFMIP5GOo70/maxresdefault.jpg\",\n    \"publishDate\": \"2022-01-14T00:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"16\"\n    ],\n    \"authors\": [\n      \"29\"\n    ]\n  },\n  {\n    \"id\": \"210\",\n    \"title\": \"ADB Podcast 179: Flibberty Widget\",\n    \"content\": \"In this episode, Chet and Romain talked with Nicole McWilliams and Petr Čermák from the London engineering office about their work on App Widgets and Digital Wellbeing.\",\n    \"url\": \"https://adbackstage.libsyn.com/flibberty-widget\",\n    \"headerImageUrl\": \"https://ssl-static.libsyn.com/p/assets/4/0/e/c/40ec1fb11096bffed959afa2a1bf1c87/adb-180-flibberty-widget.png\",\n    \"publishDate\": \"2021-11-30T00:00:00.000Z\",\n    \"type\": \"Podcast 🎙\",\n    \"topics\": [\n      \"13\"\n    ],\n    \"authors\": [\n      \"30\",\n      \"31\",\n      \"32\",\n      \"33\"\n    ]\n  },\n  {\n    \"id\": \"211\",\n    \"title\": \"ADB Podcast 180: Kotlin Magic Platform\",\n    \"content\": \"In this episode, we chat with Yigit Boyar from the Android Toolkit Team about Kotlin multi platform, while Romain provides light background music on his piano.\",\n    \"url\": \"https://adbackstage.libsyn.com/episode-180-kotlin-magic-platform\",\n    \"headerImageUrl\": \"https://ssl-static.libsyn.com/p/assets/2/6/2/5/262599d4ce76d20fa04421dee9605cbd/adb-181-kmp.png\",\n    \"publishDate\": \"2021-12-16T00:00:00.000Z\",\n    \"type\": \"Podcast 🎙\",\n    \"topics\": [\n      \"10\"\n    ],\n    \"authors\": [\n      \"30\",\n      \"31\",\n      \"32\",\n      \"34\"\n    ]\n  },\n  {\n    \"id\": \"212\",\n    \"title\": \"ADB Podcast 181: Architecture → Fewer bugs at the end\",\n    \"content\": \"In this episode, we chat with Yigit Boyar (again!) from the Android Toolkit Team and Manuel Vivo from the Developer Relations team about application architecture. The team has released new architecture guidance, and we talk about that guidance here, as well as how our architecture recommendations apply in the new Jetpack Compose world.\",\n    \"url\": \"https://adbackstage.libsyn.com/episode-181-architecture-fewer-bugs-at-the-end\",\n    \"headerImageUrl\": \"https://ssl-static.libsyn.com/p/assets/8/d/1/3/8d137b65f392a68c27a2322813b393ee/ADB_181_Architecture.png\",\n    \"publishDate\": \"2022-01-11T00:00:00.000Z\",\n    \"type\": \"Podcast 🎙\",\n    \"topics\": [\n      \"4\"\n    ],\n    \"authors\": [\n      \"31\",\n      \"31\",\n      \"32\",\n      \"23\"\n    ]\n  },\n  {\n    \"id\": \"213\",\n    \"title\": \"Android 12\",\n    \"content\": \"We released Android 12 and pushed it to the Android Open Source Project (AOSP). We introduced a new design language called Material You. We reduced the CPU time used by core system services, added performance class device capabilities, and added new features to improve performance. Users have more control of their privacy with the Privacy Dashboard and other new security and privacy features. We improved the user experience with a unified API for rich content insertion, compatible media transcoding, easier blurs and effects, AVIF image support, enhanced haptics, new camera effects/capabilities, improved native crash debugging, support for rounded screen corners, Play as you download, and Game Mode APIs.\",\n    \"url\": \"https://android-developers.googleblog.com/2021/10/android-12-is-live-in-aosp.html\",\n    \"headerImageUrl\": \"https://1.bp.blogspot.com/-mGlzRmn42Rs/YVstltyrboI/AAAAAAAAK3A/44QpoNJDeuoHhlgrRJSbk0L_ZopgFDLFACLcBGAsYHQ/s0/Android%2B12%2Blogo.png\",\n    \"publishDate\": \"2021-10-03T23:00:00.000Z\",\n    \"type\": \"DAC - Android version features\",\n    \"topics\": [\n      \"13\"\n    ],\n    \"authors\": [\n      \"14\"\n    ]\n  },\n  {\n    \"id\": \"214\",\n    \"title\": \"Compose\",\n    \"content\": \"Jetpack Compose, Android’s modern, native UI toolkit became stable and ready for you to adopt in production. It interoperates with your existing app, integrates with existing Jetpack libraries, implements Material Design with straightforward theming, supports lists with Lazy components using minimal boilerplate, and has a powerful, extensible animation system. You can learn more about working with Compose in the Compose learning path and see where we’re going in future Compose releases in the Compose roadmap.\",\n    \"url\": \"https://developer.android.com/jetpack/compose\",\n    \"headerImageUrl\": \"https://www.gstatic.com/devrel-devsite/prod/vab7ee6e3641f10848d404faa598f256587df1a361a1e70cd114230c2961b73d9/android/images/lockup.svg\",\n    \"publishDate\": \"2021-12-07T00:00:00.000Z\",\n    \"type\": \"Jetpack release 🚀\",\n    \"topics\": [\n      \"3\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"215\",\n    \"title\": \"Training\",\n    \"content\": \"This year, the Android Training Team released the final four new units of Android Basics in Kotlin.\",\n    \"url\": \"https://developer.android.com/courses/android-basics-kotlin/course\",\n    \"headerImageUrl\": \"https://developer.android.com/images/hero-assets/android-basics-kotlin.svg\",\n    \"publishDate\": \"2021-12-07T00:00:00.000Z\",\n    \"type\": \"Codelab\",\n    \"topics\": [\n      \"10\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"216\",\n    \"title\": \"Introduction to Kotlin and Jetpack \",\n    \"content\": \"Learn the basics of Jetpack KTX libraries, how to simplify callbacks with coroutines and Flow, and how to use and test Room/WorkManager APIs.\",\n    \"url\": \"https://youtu.be/nw7nnlHDkHw?list=PLWz5rJ2EKKc98e0f5ZbsgB63MdjZTFgsy\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/nw7nnlHDkHw/maxresdefault.jpg\",\n    \"publishDate\": \"2021-12-14T00:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"10\",\n      \"8\"\n    ],\n    \"authors\": [\n      \"6\"\n    ]\n  },\n  {\n    \"id\": \"217\",\n    \"title\": \"Introduction to Motion Layout\",\n    \"content\": \"Learn how to use MotionLayout and its design tool to create rich, animated experiences.\",\n    \"url\": \"https://www.youtube.com/watch?v=M1jE3W3_NTQ&list=PLWz5rJ2EKKc_PEOEHNBEyy6tPX1EgtUw2\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/M1jE3W3_NTQ/maxresdefault.jpg\",\n    \"publishDate\": \"2022-01-19T00:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"10\",\n      \"2\",\n      \"8\"\n    ],\n    \"authors\": [\n      \"35\"\n    ]\n  },\n  {\n    \"id\": \"218\",\n    \"title\": \"Introduction to WorkManager\",\n    \"content\": \"Learn how to schedule critical background work with WorkManager: from basic usage, threading, custom configuration and more.\",\n    \"url\": \"https://www.youtube.com/watch?v=NtpgWjiXEfg&list=PLWz5rJ2EKKc_J88-h0PhCO_aV0HIAs9Qk\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/NtpgWjiXEfg/maxresdefault.jpg\",\n    \"publishDate\": \"2022-03-01T00:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"8\"\n    ],\n    \"authors\": [\n      \"36\"\n    ]\n  },\n  {\n    \"id\": \"219\",\n    \"title\": \"Introduction to Navigation\",\n    \"content\": \"Learn the basics of the Navigation component, specific features of the tool and the APIs to create and navigate to destinations.\",\n    \"url\": \"https://www.youtube.com/watch?list=PLWz5rJ2EKKc9VpBMZUS9geQtc5RJ2RsUd&v=fiQiMy0HzsY&feature=emb_title\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/fiQiMy0HzsY/maxresdefault.jpg\",\n    \"publishDate\": \"2022-03-25T00:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"8\"\n    ],\n    \"authors\": [\n      \"31\"\n    ]\n  },\n  {\n    \"id\": \"220\",\n    \"title\": \"Introduction to Performance\",\n    \"content\": \"Learn about using system tracing and sampling profiling to debug performance issues in apps.\",\n    \"url\": \"https://www.youtube.com/watch?v=_5LgIrd4O5g&list=PLWz5rJ2EKKc-xjSI-rWn9SViXivBhQUnp\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/_5LgIrd4O5g/maxresdefault.jpg\",\n    \"publishDate\": \"2021-07-18T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"7\"\n    ],\n    \"authors\": [\n      \"37\"\n    ]\n  },\n  {\n    \"id\": \"221\",\n    \"title\": \"Introduction to Hilt\",\n    \"content\": \"Learn how to add and use Hilt for dependency injection in your Android app, best practices for testing with Hilt, and more advanced content.\",\n    \"url\": \"https://www.youtube.com/watch?v=mnMCgjuMJPA&list=PLWz5rJ2EKKc_9Qo-RBRYhVmME1iR4oeTK\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/mnMCgjuMJPA/maxresdefault.jpg\",\n    \"publishDate\": \"2021-08-22T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"8\"\n    ],\n    \"authors\": [\n      \"23\"\n    ]\n  },\n  {\n    \"id\": \"222\",\n    \"title\": \"Paging\",\n    \"content\": \"Learn the basics of paging, from the core types to binding them to your UI elements.\",\n    \"url\": \"https://www.youtube.com/watch?v=Pw-jhS-ucYA&list=PLWz5rJ2EKKc9L-fmWJLhyXrdPi1YKmvqS\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/Pw-jhS-ucYA/maxresdefault.jpg\",\n    \"publishDate\": \"2021-09-26T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"8\"\n    ],\n    \"authors\": [\n      \"38\"\n    ]\n  },\n  {\n    \"id\": \"223\",\n    \"title\": \"Introduction to Gradle and AGP Build APIs\\n\",\n    \"content\": \"Learn how to configure your build, customize the build process to your needs and how to write your own plugins to extend your build even further.\",\n    \"url\": \"https://www.youtube.com/watch?v=mk0XBWenod8&list=PLWz5rJ2EKKc8fyNmwKXYvA2CqxMhXqKXX\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/mk0XBWenod8/maxresdefault.jpg\",\n    \"publishDate\": \"2021-11-15T00:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"5\",\n      \"5\"\n    ],\n    \"authors\": [\n      \"25\"\n    ]\n  },\n  {\n    \"id\": \"224\",\n    \"title\": \"Google I/O\",\n    \"content\": \"At I/O we released updates in Jetpack, Compose, Android Studio tooling, Large screens, Wear OS, Testing, and more! Get caught up on all the Android videos from I/O!\",\n    \"url\": \"https://www.youtube.com/watch?v=D_mVOAXcrtc\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/D_mVOAXcrtc/maxresdefault.jpg\",\n    \"publishDate\": \"2021-05-17T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"1\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"225\",\n    \"title\": \"Android Dev Summit\",\n    \"content\": \"At Android Dev Summit we released updates on privacy and security, large screens, Android 12, Google Play & Games, Building across screens, Jetpack Compose, Modern Android Development and more. Check out all the videos from ADS!\",\n    \"url\": \"https://www.youtube.com/watch?v=WZgR5Yf1iq8\",\n    \"headerImageUrl\": \"https://i3.ytimg.com/vi/WZgR5Yf1iq8/maxresdefault.jpg\",\n    \"publishDate\": \"2021-10-26T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"1\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"226\",\n    \"title\": \"Conveying state for Accessibility\",\n    \"content\": \"In this episode of the Accessibility series, you can learn more about the StateDescription API, when to use stateDescription and contentDescription, and how to represent error states to the end user.\",\n    \"url\": \"https://youtu.be/JvWM2PjLJls\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/JvWM2PjLJls/maxresdefault.jpg\",\n    \"publishDate\": \"2021-11-30T00:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": [\n      \"39\"\n    ]\n  },\n  {\n    \"id\": \"227\",\n    \"title\": \"Take your Gradle plugin to the next step\",\n    \"content\": \"This third and last episode of the Gradle MAD Skills series teaches you how to get access to various build artifacts using the new Artifact API.\",\n    \"url\": \"https://youtu.be/SB4QlngQQW0\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/SB4QlngQQW0/maxresdefault.jpg\",\n    \"publishDate\": \"2021-11-29T00:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"5\"\n    ],\n    \"authors\": [\n      \"25\"\n    ]\n  },\n  {\n    \"id\": \"228\",\n    \"title\": \"How to write a Gradle plugin\",\n    \"content\": \"In this second episode of the Gradle MAD Skills series, Murat explains how to write your own custom Gradle plugin.\",\n    \"url\": \"https://youtu.be/LPzBVtwGxlo\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/LPzBVtwGxlo/maxresdefault.jpg\",\n    \"publishDate\": \"2021-11-22T00:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"5\"\n    ],\n    \"authors\": [\n      \"25\"\n    ]\n  },\n  {\n    \"id\": \"229\",\n    \"title\": \"Convert YUV to RGB for CameraX Image Analysis\",\n    \"content\": \"Learn about a new feature in CameraX to convert YUV, the format that CameraX produces, to RGB used for image analysis capabilities available in TensorFlow Lite, for example. Read the blog post for more information about these formats and how to use the new conversion feature.\",\n    \"url\": \"https://medium.com/androiddevelopers/convert-yuv-to-rgb-for-camerax-imageanalysis-6c627f3a0292\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/1*cuOorbZgMbRvkSSGuDGccw.png\",\n    \"publishDate\": \"2021-11-19T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"8\",\n      \"18\"\n    ],\n    \"authors\": [\n      \"40\"\n    ]\n  },\n  {\n    \"id\": \"230\",\n    \"title\": \"AppCompat, Activity, and Fragment to support multiple back stacks\",\n    \"content\": \"The 1.4.0 release of these libraries brings stable support for multiple back stacks.\",\n    \"url\": \"https://developer.android.com/jetpack/androidx/releases/appcompat#1.4.0\",\n    \"headerImageUrl\": \"https://developer.android.com/images/social/android-developers.png\",\n    \"publishDate\": \"2021-11-17T00:00:00.000Z\",\n    \"type\": \"Jetpack release 🚀\",\n    \"topics\": [\n      \"8\",\n      \"2\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"231\",\n    \"title\": \"Emoji2 adds support for modern emojis\",\n    \"content\": \"The 1.0 stable release of Emoji2 allows you to use modern emojis in your app.\",\n    \"url\": \"https://developer.android.com/jetpack/androidx/releases/emoji2#1.0.0\",\n    \"headerImageUrl\": \"https://developer.android.com/images/social/android-developers.png\",\n    \"publishDate\": \"2021-11-17T00:00:00.000Z\",\n    \"type\": \"Jetpack release 🚀\",\n    \"topics\": [\n      \"8\",\n      \"2\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"232\",\n    \"title\": \"Lifecycle introduces lifecycle-aware coroutine APIs\",\n    \"content\": \"The new 2.4 release of Lifecycle introduces repeatOnLifecycle and flowWithLifecycle.\",\n    \"url\": \"https://developer.android.com/jetpack/androidx/releases/lifecycle#2.4.0\",\n    \"headerImageUrl\": \"https://developer.android.com/images/social/android-developers.png\",\n    \"publishDate\": \"2021-11-17T00:00:00.000Z\",\n    \"type\": \"Jetpack release 🚀\",\n    \"topics\": [\n      \"8\",\n      \"4\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"233\",\n    \"title\": \"Paging release brings changes to LoadState\",\n    \"content\": \"The new 3.1 release of Paging changes the behavior of LoadState.\",\n    \"url\": \"https://developer.android.com/jetpack/androidx/releases/paging#3.1.0\",\n    \"headerImageUrl\": \"https://developer.android.com/images/social/android-developers.png\",\n    \"publishDate\": \"2021-11-17T00:00:00.000Z\",\n    \"type\": \"Jetpack release 🚀\",\n    \"topics\": [\n      \"8\",\n      \"2\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"234\",\n    \"title\": \"Wear tiles released as 1.0 stable\",\n    \"content\": \"The library that you use to build custom tiles for Wear OS devices is now stable.\",\n    \"url\": \"https://developer.android.com/jetpack/androidx/releases/wear-tiles#1.0.0\",\n    \"headerImageUrl\": \"https://developer.android.com/images/social/android-developers.png\",\n    \"publishDate\": \"2021-11-17T00:00:00.000Z\",\n    \"type\": \"Jetpack release 🚀\",\n    \"topics\": [\n      \"8\",\n      \"19\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"235\",\n    \"title\": \"About Custom Accessibility Actions\",\n    \"content\": \"The accessibility series continues on with more information on how to create custom accessibility actions to make your apps more accessible. You can provide a custom action to the accessibility services and implement logic related to the action. For more information, check out the following episode!\",\n    \"url\": \"https://youtu.be/wWDYIGk0Kdo\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/wWDYIGk0Kdo/maxresdefault.jpg\",\n    \"publishDate\": \"2021-11-17T00:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": [\n      \"39\"\n    ]\n  },\n  {\n    \"id\": \"236\",\n    \"title\": \"Improving App Startup: Lessons from the Facebook App\",\n    \"content\": \"Improving app startup time is not a trivial task and requires a deep understanding of things that affect it. This year, the Android team and the Facebook app team have been working together on metrics and sharing approaches to improve app startup. Read more about the findings in this blog post.\",\n    \"url\": \"https://android-developers.googleblog.com/2021/11/improving-app-startup-facebook-app.html\",\n    \"headerImageUrl\": \"https://1.bp.blogspot.com/-5VyrQpFJufM/YaVKxf_DanI/AAAAAAAALS4/ybeza_emDKoKP0gjiNkqfDS_ltwo0075ACLcBGAsYHQ/w1200-h630-p-k-no-nu/AppExcellence_Editorial_LessonsFromFBApp_4209x1253-01%2B%25281%2529%2B%25281%2529.png\",\n    \"publishDate\": \"2021-11-16T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"7\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"237\",\n    \"title\": \"Gradle series kicks off\",\n    \"content\": \"Murat introduces the Gradle series and everything you'll learn in it.\",\n    \"url\": \"https://youtu.be/mk0XBWenod8\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/mk0XBWenod8/maxresdefault.jpg\",\n    \"publishDate\": \"2021-11-15T00:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"5\"\n    ],\n    \"authors\": [\n      \"25\"\n    ]\n  },\n  {\n    \"id\": \"238\",\n    \"title\": \"Intro to Gradle and AGP\",\n    \"content\": \"In the first episode of the Gradle MAD Skills series, Murat explains how the Android build system works, and how to configure your build.\",\n    \"url\": \"https://youtu.be/GjPS4xDMmQY\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/GjPS4xDMmQY/maxresdefault.jpg\",\n    \"publishDate\": \"2021-11-15T00:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"5\"\n    ],\n    \"authors\": [\n      \"25\"\n    ]\n  },\n  {\n    \"id\": \"239\",\n    \"title\": \"ADB Podcast episode 179 Hosts 3, Guests 0\",\n    \"content\": \"Chet, Romain and Tor sit down to chat about the Android Developer Summit, and in particular all the new features arriving in Android Studio, along with a few other topics like Chet’s new jank stats library, the Android 12L release, and more.\",\n    \"url\": \"https://adbackstage.libsyn.com/episode-178-hosts-3-guests-0\",\n    \"headerImageUrl\": \"http://assets.libsyn.com/show/332855?height=250&width=250&overlay=true\",\n    \"publishDate\": \"2021-11-15T00:00:00.000Z\",\n    \"type\": \"Podcast 🎙\",\n    \"topics\": [\n      \"5\",\n      \"7\",\n      \"13\"\n    ],\n    \"authors\": [\n      \"31\"\n    ]\n  },\n  {\n    \"id\": \"240\",\n    \"title\": \"The problem with emojis and how emoji2 can help out\",\n    \"content\": \"Meghan wrote about the new emoji2 library that just became stable.\",\n    \"url\": \"https://medium.com/androiddevelopers/support-modern-emoji-99f6dea8e57f\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/1*yAOOlpXKKUl5nWWsPkNb7g.png\",\n    \"publishDate\": \"2021-11-12T00:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"8\",\n      \"2\"\n    ],\n    \"authors\": [\n      \"15\"\n    ]\n  },\n  {\n    \"id\": \"241\",\n    \"title\": \"Paging Q&A\",\n    \"content\": \"In this live session, TJ and Dustin answered your questions in the usual live Q&A format.\",\n    \"url\": \"https://youtu.be/8i6vrlbIVCc\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/8i6vrlbIVCc/maxresdefault.jpg\",\n    \"publishDate\": \"2021-11-11T00:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"242\",\n    \"title\": \"Thanks for helping us reach 1M YouTube Subscribers\",\n    \"content\": \"Thank you everyone for following the Now in Android series and everything the Android Developers YouTube channel has to offer. During the Android Developer Summit, our YouTube channel reached 1 million subscribers! Here’s a small video to thank you all.\",\n    \"url\": \"https://youtu.be/-fJ6poHQrjM\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg\",\n    \"publishDate\": \"2021-11-09T00:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"1\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"243\",\n    \"title\": \"Community tip on Paging\",\n    \"content\": \"Tips for using the Paging library from the developer community\",\n    \"url\": \"https://youtu.be/r5JgIyS3t3s\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/r5JgIyS3t3s/maxresdefault.jpg\",\n    \"publishDate\": \"2021-11-08T00:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"244\",\n    \"title\": \"Transformations and customisations in the Paging Library\",\n    \"content\": \"A demonstration of different operations that can be performed with Paging. Transformations like inserting separators, when to create a new pager, and customisation options for consuming PagingData.\",\n    \"url\": \"https://youtu.be/ZARz0pjm5YM\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg\",\n    \"publishDate\": \"2021-11-01T00:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": [\n      \"38\"\n    ]\n  },\n  {\n    \"id\": \"245\",\n    \"title\": \"New Compose for Wear OS codelab\",\n    \"content\": \"In this codelab, you can learn how Wear OS can work with Compose, what Wear OS specific composables are available, and more!\",\n    \"url\": \"https://developer.android.com/codelabs/compose-for-wear-os\",\n    \"headerImageUrl\": \"https://developer.android.com/codelabs/compose-for-wear-os/img/4d28d16f3f514083.png\",\n    \"publishDate\": \"2021-10-27T23:00:00.000Z\",\n    \"type\": \"Codelab\",\n    \"topics\": [\n      \"3\",\n      \"19\"\n    ],\n    \"authors\": [\n      \"41\"\n    ]\n  },\n  {\n    \"id\": \"246\",\n    \"title\": \"Building apps which are private by design\",\n    \"content\": \"Sara N-Marandi, product manager, and Yacine Rezgui, developer relations engineer, provided guidelines and best practices on how to build apps that are private by design, covered new privacy features in Android 12 and previewed upcoming Android concepts.\",\n    \"url\": \"https://youtu.be/hBVwr2ErQCw\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/hBVwr2ErQCw/maxresdefault.jpg\",\n    \"publishDate\": \"2021-10-26T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"11\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"247\",\n    \"title\": \"Memory Safety Tools\",\n    \"content\": \"Serban Constantinescu, product manager, talked about the Memory Safety Tools that became available starting in Android 11 and have continued to evolve in Android 12. These tools can help address memory bugs and improve the quality and security of your application.\",\n    \"url\": \"https://youtu.be/JqLcTFpXreg\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/JqLcTFpXreg/maxresdefault.jpg\",\n    \"publishDate\": \"2021-10-26T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"11\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"248\",\n    \"title\": \"Increasing User Transparency with Privacy Dashboard\",\n    \"content\": \"Android is ever evolving in its quest to protect users’ privacy. In Android 12, the platform increases transparency by introducing Privacy Dashboard, which gives users a simple and clear timeline view of the apps that have accessed location, microphone and camera within the past 24 hours. \",\n    \"url\": \"https://medium.com/androiddevelopers/increasing-user-transparency-with-privacy-dashboard-23064f2d7ff6\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/1*cgaSAY9AvPWlndLimzIIzQ.png\",\n    \"publishDate\": \"2021-10-26T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"11\"\n    ],\n    \"authors\": [\n      \"15\"\n    ]\n  },\n  {\n    \"id\": \"249\",\n    \"title\": \"The most unusual and interesting security issues addressed last year\",\n    \"content\": \"Lilian Young, software engineer, presented a selection of the most unusual, intricate, and interesting security issues addressed in the last year. Developers and researchers are able to contribute to the security of the Android platform by submitting to the Android Vulnerability Rewards Program.\",\n    \"url\": \"https://medium.com/androiddevelopers/now-in-android-50-ads-special-9934422f8dd1\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/0*6h0XYdyki_1jfImJ\",\n    \"publishDate\": \"2021-10-26T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"11\"\n    ],\n    \"authors\": [\n      \"43\"\n    ]\n  },\n  {\n    \"id\": \"250\",\n    \"title\": \"New Data Safety section in the Play Console\",\n    \"content\": \"The new Data safety section will give you a simple way to showcase your app’s overall safety. It gives you a place to give users deeper insight into your app’s privacy and security practices, and explain the data your app may collect and why — all before users install.\",\n    \"url\": \"https://youtu.be/J7TM0Yy0aTQ\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/J7TM0Yy0aTQ/maxresdefault.jpg\",\n    \"publishDate\": \"2021-10-26T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"11\",\n      \"12\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"251\",\n    \"title\": \"Building Android UIs for any screen size\",\n    \"content\": \"Clara Bayarri, engineering manager and Daniel Jacobson, product manager, talked about the state of the ecosystem, focusing on new design guidance, APIs, and tools to help you make the most of your UI on different screen sizes.\",\n    \"url\": \"https://youtu.be/ir3LztqbeRI\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/ir3LztqbeRI/maxresdefault.jpg\",\n    \"publishDate\": \"2021-10-26T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"252\",\n    \"title\": \"What's new for large screens & foldables\",\n    \"content\": \"Emilie Roberts, Chrome OS developer advocate and Andrii Kulian, Android software engineer, introduced new features focused specifically on making apps look great on large screens, foldables, and Chrome OS. \",\n    \"url\": \"https://youtu.be/6-925K3hMHU\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/6-925K3hMHU/maxresdefault.jpg\",\n    \"publishDate\": \"2021-10-26T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"253\",\n    \"title\": \"Enable great input support for all devices\",\n    \"content\": \"Users expect seamless experiences when using keyboards, mice, and stylus. Emilie Roberts taught us how to handle common keyboard and mouse input events and how to get started with more advanced support like keyboard shortcuts, low-latency styluses, MIDI, and more.\",\n    \"url\": \"https://youtu.be/piLEZYTc_4g\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/piLEZYTc_4g/maxresdefault.jpg\",\n    \"publishDate\": \"2021-10-26T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"254\",\n    \"title\": \"Best practices for video apps on foldable devices\",\n    \"content\": \"Francesco Romano, developer advocate, and Will Chan, product manager at Zoom explored new user experiences made possible by the foldable form factor, focusing on video conferencing and media applications. \",\n    \"url\": \"https://youtu.be/DBAek_P0nEw\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/DBAek_P0nEw/maxresdefault.jpg\",\n    \"publishDate\": \"2021-10-26T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"2\",\n      \"18\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"255\",\n    \"title\": \"Design beautiful apps on foldables and large screens\",\n    \"content\": \"Liam Spradlin, design advocate, and Jonathan Koren, developer relations engineer, talked about how to design and test Android applications that look and feel great across device types and screen sizes, from tablets to foldables to Chrome OS.\",\n    \"url\": \"https://youtu.be/DJeJIJKOUbI\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/DJeJIJKOUbI/maxresdefault.jpg\",\n    \"publishDate\": \"2021-10-26T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"256\",\n    \"title\": \"12L and new Android APIs and tools for large screens\",\n    \"content\": \"Dave Burke, vice president of engineering, wrote a post covering the developer preview of 12L, an upcoming feature drop that makes Android 12 even better on large screens. \",\n    \"url\": \"https://android-developers.googleblog.com/2021/10/12L-preview-large-screens.html\",\n    \"headerImageUrl\": \"https://1.bp.blogspot.com/-sjT5kFGiQtg/YXlpg0uByLI/AAAAAAAARJk/XHO_uo5bRJcMeQVm0Fn1wN-qe54FGI7MgCLcBGAsYHQ/w1200-h630-p-k-no-nu/12L-devices-hero.png\",\n    \"publishDate\": \"2021-10-26T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"2\",\n      \"13\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"257\",\n    \"title\": \"New features in ML Kit: Text Recognition V2 & Pose Detections\",\n    \"content\": \"Zongmin Sun, software engineer, and Valentin Bazarevsky, MediaPipe Engineer, talked about Text Recognition V2 & Pose Detection, recently-released features in ML Kit. \",\n    \"url\": \"https://youtu.be/9EKQ0UC04S8\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/9EKQ0UC04S8/maxresdefault.jpg\",\n    \"publishDate\": \"2021-10-26T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"8\",\n      \"2\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"258\",\n    \"title\": \"How to retain users with Android backup and restore\",\n    \"content\": \"In this talk, Martin Millmore, engineering manager, and Ruslan Tkhakokhov, software engineer, explored the benefits of transferring users’ data to a new device, using Backup and Restore to achieve that in a simple and secure way.\",\n    \"url\": \"https://youtu.be/bg2drEhz1_s\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/bg2drEhz1_s/maxresdefault.jpg\",\n    \"publishDate\": \"2021-10-26T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"13\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"259\",\n    \"title\": \"Compatibility changes in Android 12\",\n    \"content\": \"Developer relations engineers Kseniia Shumelchyk and Slava Panasenko talked about new Android 12 features and changes. They shared tools and techniques to ensure that apps are compatible with the next Android release and users can take advantage of new features, along with app developer success stories.\",\n    \"url\": \"https://youtu.be/fCMJmV6nqGo\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/fCMJmV6nqGo/maxresdefault.jpg\",\n    \"publishDate\": \"2021-10-26T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"13\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"260\",\n    \"title\": \"Building great experiences for Novice Internet Users\",\n    \"content\": \"Learn the principles to help craft great experiences for the novice Internet user segment from Mrinal Sharma, UX manager, and Amrit Sanjeev, developer relations engineer. They highlight the gap between nascent and tech savvy user segments and suggest strategies in areas to improve the overall user experience. Factors like low functional literacy, being multilingual by default, being less digitally confident, and having no prior internet experience requires that we rethink the way we build apps for these users.\",\n    \"url\": \"https://youtu.be/Sf_TauUY4LE\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/Sf_TauUY4LE/maxresdefault.jpg\",\n    \"publishDate\": \"2021-10-26T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"261\",\n    \"title\": \"Android Basics in Kotlin course 🧑‍💻\",\n    \"content\": \"Android Basics in Kotlin teaches people with no programming experience how to build simple Android apps. Since the first learning units were released in 2020, over 100,000 beginners have completed it! Today, we’re excited to share that the final unit has been released, and the full Android Basics in Kotlin course is now available.\",\n    \"url\": \"https://android-developers.googleblog.com/2021/10/announcing-android-basics-in-kotlin.html\",\n    \"headerImageUrl\": \"https://1.bp.blogspot.com/-BmlW7k8RhME/YWRvsOes9aI/AAAAAAAAQ_g/FpFS6_new9Y7vdzP7P4RPs_x4WHVi4yxQCLcBGAsYHQ/w1200-h630-p-k-no-nu/Android-announcing-android-basics-in-Kotlin-course-16x9.png\",\n    \"publishDate\": \"2021-10-20T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"10\"\n    ],\n    \"authors\": [\n      \"25\"\n    ]\n  },\n  {\n    \"id\": \"262\",\n    \"title\": \"Updated Widget docs\",\n    \"content\": \"Widgets can make a huge impact on your user’s home screen! We updated the App Widgets documentation with the recent changes in the latest OS versions. New pages about how to create a simple widget, an advanced widget, and how to provide flexible widget layouts.\",\n    \"url\": \"https://developer.android.com/guide/topics/appwidgets\",\n    \"headerImageUrl\": \"https://www.gstatic.com/devrel-devsite/prod/vab7ee6e3641f10848d404faa598f256587df1a361a1e70cd114230c2961b73d9/android/images/lockup.svg\",\n    \"publishDate\": \"2021-10-20T23:00:00.000Z\",\n    \"type\": \"Docs 📑\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"263\",\n    \"title\": \"Extend AGP by creating your own plugins\",\n    \"content\": \"The Android Gradle Plugin (AGP) contains extension points for plugins to control build inputs and extend its functionality. Starting in version 7.0, AGP has a set of official, stable APIs that you can rely on. We also have a new documentation page that walks you through this and explains how to create your own plugins.\",\n    \"url\": \"https://developer.android.com/studio/build/extend-agp\",\n    \"headerImageUrl\": \"https://developer.android.com/images/social/android-developers.png\",\n    \"publishDate\": \"2021-10-20T23:00:00.000Z\",\n    \"type\": \"Docs 📑\",\n    \"topics\": [\n      \"5\",\n      \"5\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"264\",\n    \"title\": \"Revamped Compose Basics Codelab\",\n    \"content\": \"If you’re planning to start learning Jetpack Compose, our modern toolkit for building native Android UI, it’s your lucky day! We just revamped the Basics Jetpack Compose codelab to help you learn the core concepts of Compose, and only with this, you’ll see how much it improves building Android UIs.\",\n    \"url\": \"https://developer.android.com/codelabs/jetpack-compose-basics\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/k3jvNqj4m08/maxresdefault.jpg\",\n    \"publishDate\": \"2021-10-20T23:00:00.000Z\",\n    \"type\": \"Codelab\",\n    \"topics\": [\n      \"3\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"265\",\n    \"title\": \"Material components in Compose\",\n    \"content\": \"We added a new Material Components and layouts page that goes over the different Material components in Compose such as backdrop, app bars, modal drawers, etc.!\",\n    \"url\": \"https://developer.android.com/jetpack/compose/layouts/material\",\n    \"headerImageUrl\": \"https://developer.android.com/images/jetpack/compose/layouts/material/material_components.png\",\n    \"publishDate\": \"2021-10-20T23:00:00.000Z\",\n    \"type\": \"Docs 📑\",\n    \"topics\": [\n      \"3\",\n      \"2\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"266\",\n    \"title\": \"How to implement a custom design system\",\n    \"content\": \"How to implement a custom design system in Compose\",\n    \"url\": \"https://developer.android.com/jetpack/compose/themes/custom\",\n    \"headerImageUrl\": \"https://developer.android.com/images/social/android-developers.png\",\n    \"publishDate\": \"2021-10-20T23:00:00.000Z\",\n    \"type\": \"Docs 📑\",\n    \"topics\": [\n      \"3\",\n      \"2\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"267\",\n    \"title\": \"The anatomy of a theme\",\n    \"content\": \"Understanding the anatomy of a Compose theme\",\n    \"url\": \"https://developer.android.com/jetpack/compose/themes/anatomy\",\n    \"headerImageUrl\": \"https://developer.android.com/images/social/android-developers.png\",\n    \"publishDate\": \"2021-10-20T23:00:00.000Z\",\n    \"type\": \"Docs 📑\",\n    \"topics\": [\n      \"3\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"268\",\n    \"title\": \"Paging 📑  Displaying data and its loading state\",\n    \"content\": \"In the third episode of the Paging video series, TJ adds a local cache to pull from and refresh only when necessary, making use of Room . The local cache acts as the single source of truth for paging data.\",\n    \"url\": \"https://www.youtube.com/watch?v=OHH_FPbrjtA\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/OHH_FPbrjtA/maxresdefault.jpg\",\n    \"publishDate\": \"2021-10-17T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"9\",\n      \"2\"\n    ],\n    \"authors\": [\n      \"38\"\n    ]\n  },\n  {\n    \"id\": \"269\",\n    \"title\": \"Data safety in the Play Console 🔒\",\n    \"content\": \"Google Play is rolling out the Data safety form in the Google Play Console. With the new Data safety section, developers will now have a transparent way to show users if and how they collect, share, and protect user data, before users install an app.\\nRead the blog post to learn more about how to submit your app information in Play Console, how to get prepared, and what your users will see in your app’s store listing starting February.\",\n    \"url\": \"https://android-developers.googleblog.com/2021/10/launching-data-safety-in-play-console.html\",\n    \"headerImageUrl\": \"https://1.bp.blogspot.com/-Zde9ioLE3SY/YWh7qiquXKI/AAAAAAAARCU/m6D-qJJe6QowYPcDWUtb3-YzFGn9xIaUwCLcBGAsYHQ/w1200-h630-p-k-no-nu/Android-get-ready-to-sumbit-your-data-safety-secton-social.png\",\n    \"publishDate\": \"2021-10-17T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"11\",\n      \"12\"\n    ],\n    \"authors\": [\n      \"10\"\n    ]\n  },\n  {\n    \"id\": \"270\",\n    \"title\": \"Honor every photo - How cameras capture images\",\n    \"content\": \"Episode 177: Honor every photon. In this episode, Chet, Roman, and Tor have a chat with Bart Wronski from the Google Research team, discussing the camera pipeline that powers the Pixel phones. How cameras capture images, how the algorithms responsible for Pixel’s beautiful images, HDR+ or Night Sight mode works, and more!\",\n    \"url\": \"https://adbackstage.libsyn.com/episode-177-honor-every-photon\",\n    \"headerImageUrl\": \"http://assets.libsyn.com/show/332855?height=250&width=250&overlay=true\",\n    \"publishDate\": \"2021-10-17T23:00:00.000Z\",\n    \"type\": \"Podcast 🎙\",\n    \"topics\": [\n      \"18\"\n    ],\n    \"authors\": [\n      \"31\"\n    ]\n  },\n  {\n    \"id\": \"271\",\n    \"title\": \"Accessibility series 🌐 - Touch targets\",\n    \"content\": \"The accessibility series continues on with more information on how to follow basic accessibility principles to make sure that your app can be used by as many users as possible.\\nIn general, you should ensure that interactive elements have a width and height of at least 48dp! In the touch targets episode, you’ll learn about a few ways in which you can make this happen.\",\n    \"url\": \"https://www.youtube.com/watch?v=Dqqbe8IFBA4\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/Dqqbe8IFBA4/maxresdefault.jpg\",\n    \"publishDate\": \"2021-10-16T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"14\"\n    ],\n    \"authors\": [\n      \"39\"\n    ]\n  },\n  {\n    \"id\": \"272\",\n    \"title\": \"Using the CameraX Exposure Compensation API\",\n    \"content\": \"This blog post by Wenhung Teng talks about how to use the CameraX Exposure Compensation that makes it much simpler to quickly take images with exceptional quality.\",\n    \"url\": \"https://medium.com/androiddevelopers/using-camerax-exposure-compensation-api-11fd75785bf\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/1*zinEvf1keSZYuZojr31ehQ.png\",\n    \"publishDate\": \"2021-10-12T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"18\"\n    ],\n    \"authors\": [\n      \"44\"\n    ]\n  },\n  {\n    \"id\": \"273\",\n    \"title\": \"Compose for Wear OS in Developer preview ⌚\",\n    \"content\": \"We’re bringing the best of Compose to Wear OS as well, with built-in support for Material You to help you create beautiful apps with less code. Read the following article to review the main composables for Wear OS we’ve built and point you towards resources to get started using them.\",\n    \"url\": \"https://android-developers.googleblog.com/2021/10/compose-for-wear-os-now-in-developer.html\",\n    \"headerImageUrl\": \"https://1.bp.blogspot.com/-RkL3Yokn3XE/YWWmbuX8E7I/AAAAAAAAQ_o/CEmNJ5_mfq0kScxkFGoMpf1BlU5-uBHjACLcBGAsYHQ/w1200-h630-p-k-no-nu/Android-compose-for-wear-os-now-in-dev-review-header-dark.png\",\n    \"publishDate\": \"2021-10-11T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"19\",\n      \"3\"\n    ],\n    \"authors\": [\n      \"41\"\n    ]\n  },\n  {\n    \"id\": \"274\",\n    \"title\": \"Paging 📑  How to fetch data and bind the PagingData to the UI\",\n    \"content\": \"The series on Paging continues on with more content! In the second episode, TJ shows how to fetch data and bind the PagingData to the UI, including headers and footers.\",\n    \"url\": \"https://www.youtube.com/watch?v=C0H54K63Lww\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/C0H54K63Lww/maxresdefault.jpg\",\n    \"publishDate\": \"2021-10-10T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": [\n      \"38\"\n    ]\n  },\n  {\n    \"id\": \"275\",\n    \"title\": \"Room adds support for Kotlin Symbol Processing\",\n    \"content\": \"Yigit Boyar wrote the story about how Room added support for Kotlin Symbol Processing (KSP). Spoiler: it wasn’t easy, but it was definitely worth it.\",\n    \"url\": \"https://medium.com/androiddevelopers/room-kotlin-symbol-processing-24808528a28e\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/1*yM7Lf4dC_hwse6YmoCO4uQ.png\",\n    \"publishDate\": \"2021-10-09T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"9\"\n    ],\n    \"authors\": [\n      \"34\"\n    ]\n  },\n  {\n    \"id\": \"276\",\n    \"title\": \"Apply special effects to images with the CameraX Extensions API\",\n    \"content\": \"Have you ever wanted to apply special effects such as HDR or Night mode when taking pictures from your app? CameraX is here to help you! In this article by Charcoal Chen, learn how to do that using the new ExtensionsManager available in the camera-extensions Jetpack library. \",\n    \"url\": \"https://medium.com/androiddevelopers/apply-special-effects-to-images-with-the-camerax-extensions-api-d1a169b803d3\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/1*GZmhCFMCrG4L_mOtwSb0zA.png\",\n    \"publishDate\": \"2021-10-06T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"18\"\n    ],\n    \"authors\": [\n      \"45\"\n    ]\n  },\n  {\n    \"id\": \"277\",\n    \"title\": \"Wear OS Jetpack libraries now in stable\",\n    \"content\": \"The Wear OS Jetpack libraries are now in stable.\",\n    \"url\": \"https://android-developers.googleblog.com/2021/09/wear-os-jetpack-libraries-now-in-stable.html\",\n    \"headerImageUrl\": \"https://1.bp.blogspot.com/-9zeEGNCG_As/YUD1UO_3kkI/AAAAAAAAQ8k/tCFBpTCwU4MEQHQNB9XzTOXSf6hd9TkQQCLcBGAsYHQ/w1200-h630-p-k-no-nu/image1.png\",\n    \"publishDate\": \"2021-09-14T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"8\",\n      \"19\"\n    ],\n    \"authors\": [\n      \"41\"\n    ]\n  },\n  {\n    \"id\": \"278\",\n    \"title\": \"Android Dev Summit returns on October 27-28, 2021! 📆\",\n    \"content\": \"Join us October 27–28 for Android Dev Summit 2021! The show kicks off at 10 AM PST on October 27 with The Android Show: a technical keynote where you’ll hear all the latest developer news and updates. From there, we have over 30 sessions on a range of technical Android development topics, and we’ll be answering your #AskAndroid questions live.\",\n    \"url\": \"https://developer.android.com/dev-summit\",\n    \"headerImageUrl\": \"https://developer.android.com/dev-summit/images/android-dev-summit-2021.png\",\n    \"publishDate\": \"2021-10-05T23:00:00.000Z\",\n    \"type\": \"Event 📆\",\n    \"topics\": [\n      \"1\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"279\",\n    \"title\": \"Android 12 is live in AOSP! 🤖\",\n    \"content\": \"We released Android 12 and pushed it to the Android Open Source Project (AOSP). It will be coming to devices later on this year. Thank you for your feedback during the beta.\\nAndroid 12 introduces a new design language called Material You along with redesigned widgets, notification UI updates, stretch overscroll, and app launch splash screens. We reduced the CPU time used by core system services, added performance class device capabilities, made ML accelerator drivers updatable outside of platform releases, and prevented apps from launching foreground services from the background and using notification trampolines to improve performance. The new Privacy Dashboard, approximate location, microphone and camera indicators/toggles, and nearby device permissions give users more insight into and control over privacy. We improved the user experience with a unified API for rich content insertion, compatible media transcoding, easier blurs and effects, AVIF image support, enhanced haptics, new camera effects/capabilities, improved native crash debugging, support for rounded screen corners, Play as you download, and Game Mode APIs.\",\n    \"url\": \"https://android-developers.googleblog.com/2021/10/android-12-is-live-in-aosp.html\",\n    \"headerImageUrl\": \"https://1.bp.blogspot.com/-7dVmEfR3mJs/YVst2TdY16I/AAAAAAAAK3I/pLnt0r5S-pIaJwcSNsNBqT8w2Y4Ej0yaQCLcBGAsYHQ/w1200-h630-p-k-no-nu/Android%2B12.jpeg\",\n    \"publishDate\": \"2021-10-03T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"13\"\n    ],\n    \"authors\": [\n      \"14\"\n    ]\n  },\n  {\n    \"id\": \"280\",\n    \"title\": \"Improved Google Play Console user management 🧑‍💼\",\n    \"content\": \"The user and permission tools in Play Console have a new, decluttered interface and new team management features, making it easier to make sure every team member has the right set of permissions to fulfill their responsibilities without overexposing unrelated business data.\\nWe’ve rewritten permission names and descriptions, clarified differentiation between account and app-level permissions, added new search, filtering, and batch-editing capabilities, and added the ability to export this information to a CSV file. In addition, Play Console users can request access to actions with a justification, and we’ve introduced permission groups to make it easier to assign multiple permissions at once to users that share the same or similar roles.\",\n    \"url\": \"https://android-developers.googleblog.com/2021/09/improved-google-play-console-user.html\",\n    \"headerImageUrl\": \"https://1.bp.blogspot.com/-vw3eaKdwzVU/YUjvyJ6zy2I/AAAAAAAAQ9s/m39byf56P8Icog5e5TgCbu9et0VCZh1iACLcBGAsYHQ/w1200-h630-p-k-no-nu/PlayConsole-revamped-user-management-01.png\",\n    \"publishDate\": \"2021-09-20T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"12\"\n    ],\n    \"authors\": [\n      \"46\"\n    ]\n  },\n  {\n    \"id\": \"281\",\n    \"title\": \"Making Permissions auto-reset available to billions more devices 🔐\",\n    \"content\": \"Android 11 introduced permission auto-reset, automatically resetting an app’s runtime permissions when it isn’t used for a few months. In December 2021, we are starting to roll this feature out to devices with Google Play services running Android 6.0 (API level 23) or higher for apps targeting Android 11 (API level 30) or higher. Users can manually enable permission auto-reset for apps targeting API levels 23 to 29.\\nSome apps and permissions are automatically exempted from revocation, like active Device Administrator apps used by enterprises, and permissions fixed by enterprise policy. If your app is expected to work primarily in the background without user interaction, you can ask the user to prevent the system from resetting your app’s permissions.\",\n    \"url\": \"https://android-developers.googleblog.com/2021/09/making-permissions-auto-reset-available.html\",\n    \"headerImageUrl\": \"https://1.bp.blogspot.com/-W3UAh-gyf3Y/YUJehjKWQjI/AAAAAAAAQ84/zkURLgqMRa4VZK3Is3ENNYG_OjXJxx2pgCLcBGAsYHQ/w1200-h630-p-k-no-nu/Android-making-permissions-auto-reset-social-v2.png\",\n    \"publishDate\": \"2021-09-16T23:00:00.000Z\",\n    \"type\": \"DAC - Android version features\",\n    \"topics\": [\n      \"11\"\n    ],\n    \"authors\": [\n      \"47\"\n    ]\n  },\n  {\n    \"id\": \"282\",\n    \"title\": \"Migrating from Dagger to Hilt\",\n    \"content\": \"While you will eventually want to migrate all your existing Dagger modules over to Hilt’s built in components, you can start by migrating application-wide components to Hilt’s singleton component. This episode explains how.\",\n    \"url\": \"https://www.youtube.com/watch?v=Xt1_3Nq4lD0&t=15s\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/Xt1_3Nq4lD0/hqdefault.jpg\",\n    \"publishDate\": \"2021-09-19T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"4\"\n    ],\n    \"authors\": [\n      \"48\"\n    ]\n  },\n  {\n    \"id\": \"283\",\n    \"title\": \"ADB Podcast Episode 175: Creating delightful user experiences with Lottie animations\",\n    \"content\": \"In this episode, Chet, Romain and Tor have a chat with Gabriel Peal from Tonal, well known for his contributions to the Android community on projects such as Mavericks and Lottie. They talked about Lottie and how it helps designers and developers deliver more delightful user experiences by taking complex animations designed in specialized authoring tools such as After Effects, and rendering them efficiently on mobile devices. They also explored the challenges of designing and implementing a rendering engine such as Lottie.\",\n    \"url\": \"http://adbackstage.libsyn.com/episode-175-lottie\",\n    \"headerImageUrl\": \"http://assets.libsyn.com/show/332855?height=250&width=250&overlay=true\",\n    \"publishDate\": \"2021-09-13T23:00:00.000Z\",\n    \"type\": \"Podcast 🎙\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": [\n      \"31\"\n    ]\n  },\n  {\n    \"id\": \"284\",\n    \"title\": \"Hilt extensions\",\n    \"content\": \"This episode explains how to write your own Hilt Extensions. Hilt Extensions allow you to extend Hilt support to new libraries. Extensions can be created for common patterns in projects, to support non-standard member injection, mirroring bindings, and more.\",\n    \"url\": \"https://medium.com/androiddevelopers/hilt-extensions-in-the-mad-skills-series-f2ed6fcba5fe\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/1*a_ZJwMHs17SmEFr3uEbxDg.png\",\n    \"publishDate\": \"2021-09-12T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"4\"\n    ],\n    \"authors\": [\n      \"49\"\n    ]\n  },\n  {\n    \"id\": \"285\",\n    \"title\": \"Labeling images for Accessibility\",\n    \"content\": \"This Accessibilities series episode covers labeling images for accessibility, such as content descriptions for ImageViews and ImageButtons.\",\n    \"url\": \"https://youtu.be/O2DeSITnzFk\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/O2DeSITnzFk/maxresdefault.jpg\",\n    \"publishDate\": \"2021-09-09T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"14\"\n    ],\n    \"authors\": [\n      \"28\"\n    ]\n  },\n  {\n    \"id\": \"286\",\n    \"title\": \"ADB Podcast Episode 174: Compose in Android Studio\",\n    \"content\": \"In this episode, Tor and Nick are joined by Chris Sinco, Diego Perez and Nicolas Roard to discuss the features added to Android Studio for Jetpack Compose. Tune in as they discuss the Compose preview, interactive preview, animation inspector, and additions to the Layout inspector along with their approach to creating tooling to support Compose’s code-centric system.\",\n    \"url\": \"http://adbackstage.libsyn.com/episode-174-compose-tooling\",\n    \"headerImageUrl\": \"http://assets.libsyn.com/content/110962067?height=250&width=250&overlay=true\",\n    \"publishDate\": \"2021-09-08T23:00:00.000Z\",\n    \"type\": \"Podcast 🎙\",\n    \"topics\": [\n      \"5\",\n      \"3\"\n    ],\n    \"authors\": [\n      \"32\"\n    ]\n  },\n  {\n    \"id\": \"287\",\n    \"title\": \"Hilt under the hood\",\n    \"content\": \"This episode dives into how the Hilt annotation processors generate code, and how the Hilt Gradle plugin works behind the scenes to improve the overall experience when using Hilt with Gradle.\",\n    \"url\": \"https://medium.com/androiddevelopers/mad-skills-series-hilt-under-the-hood-9d89ee227059\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/1*a_ZJwMHs17SmEFr3uEbxDg.png\",\n    \"publishDate\": \"2021-09-07T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"4\"\n    ],\n    \"authors\": [\n      \"50\"\n    ]\n  },\n  {\n    \"id\": \"288\",\n    \"title\": \"Trackr comes to the Big Screen\",\n    \"content\": \"A blog post on Trackr, a sample task management app where we showcase Modern Android Development best practices. This post takes you through how applying Material Design and responsive patterns produced a more refined and intuitive user experience on large screen devices.\",\n    \"url\": \"https://medium.com/androiddevelopers/trackr-comes-to-the-big-screen-9f13c6f927bf\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/1*678DlYtu4G7wFrq30FQ7Mw.png\",\n    \"publishDate\": \"2021-09-06T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": [\n      \"51\"\n    ]\n  },\n  {\n    \"id\": \"289\",\n    \"title\": \"Accessibility services and the Android Accessibility model\",\n    \"content\": \"This Accessibilities series episode covers accessibility services like TalkBack, Switch Access and Voice Access and how they help users interact with your apps. Android’s accessibility framework allows you to write one app and the framework takes care of providing the information needed by different accessibility services.\",\n    \"url\": \"https://youtu.be/LxKat_m7mHk\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/LxKat_m7mHk/maxresdefault.jpg\",\n    \"publishDate\": \"2021-09-02T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"14\"\n    ],\n    \"authors\": [\n      \"39\"\n    ]\n  },\n  {\n    \"id\": \"290\",\n    \"title\": \"New Accessibility Pathway\",\n    \"content\": \"Want even more accessibility? You are in luck, check out this entire new learning pathway aimed at teaching you how to make your app more accessible.\",\n    \"url\": \"https://developer.android.com/courses/pathways/make-your-android-app-accessible\",\n    \"headerImageUrl\": \"https://developers.google.com/profile/badges/playlists/make-your-android-app-accessible/badge.svg\",\n    \"publishDate\": \"2021-08-31T23:00:00.000Z\",\n    \"type\": \"\",\n    \"topics\": [\n      \"14\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"291\",\n    \"title\": \"Jetpack Compose 1.0 stable is released\",\n    \"content\": \"Jetpack Compose, Android’s modern, native UI toolkit is now stable and ready for you to adopt in production. It interoperates with your existing app, integrates with existing Jetpack libraries, implements Material Design with straightforward theming, supports lists with Lazy components using minimal boilerplate, and has a powerful, extensible animation system.\",\n    \"url\": \"https://android-developers.googleblog.com/2021/07/jetpack-compose-announcement.html\",\n    \"headerImageUrl\": \"https://1.bp.blogspot.com/-9MiK78CFMLM/YQFurOq9AII/AAAAAAAAQ1A/lKj5GiDnO_MkPLb72XqgnvD5uxOsHO-eACLcBGAsYHQ/w1200-h630-p-k-no-nu/Android-Compose-1.0-header-v2.png\",\n    \"publishDate\": \"2021-07-27T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"3\"\n    ],\n    \"authors\": [\n      \"52\"\n    ]\n  },\n  {\n    \"id\": \"292\",\n    \"title\": \"Android Studio Artic Fox stable is released\",\n    \"content\": \"Android Studio Arctic Fox is now available in the stable release channel. Arctic Fox brings Jetpack Compose to life with Compose Preview, Deploy Preview, Compose support in the Layout Inspector, and Live Editing of literals. Compose Preview works with the @Preview annotation to let you instantly see the impact of changes across multiple themes, screen sizes, font sizes, and more. Deploy Preview deploys snippets of your Compose code to a device or emulator for quick testing. Layout inspector now works with apps written fully in Compose as well as apps that have Compose alongside Views, allowing you to explore your layouts and troubleshoot. With Live Edit of literals, you can edit literals such as strings, numbers, booleans, etc. and see the immediate results change in previews, the emulator, or on a physical device — all without having to compile.\\n\",\n    \"url\": \"https://android-developers.googleblog.com/2021/07/android-studio-arctic-fox-202031-stable.html\",\n    \"headerImageUrl\": \"https://1.bp.blogspot.com/-cmcRT5BGOTY/YQBKC6asA0I/AAAAAAAAQzg/hZrde9Sgx881Wdf-c__VMkTvsKoVjOwsACLcBGAsYHQ/w1200-h630-p-k-no-nu/Arctic_Fox_Splash_2x%2B%25281%2529.png\",\n    \"publishDate\": \"2021-07-27T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"5\",\n      \"3\"\n    ],\n    \"authors\": [\n      \"53\"\n    ]\n  },\n  {\n    \"id\": \"293\",\n    \"title\": \"User control, privacy, security, and safety\",\n    \"content\": \"Play announced new updates to bolster user control, privacy, and security. The post covered advertising ID updates, including zeroing out the advertising ID when users opt out of interest-based advertising or ads personalization, the developer preview of the app set ID, enhanced protection for kids, and policy updates around dormant accounts and users of the AccessibilityService API.\",\n    \"url\": \"https://android-developers.googleblog.com/2021/07/announcing-policy-updates-to-bolster.html\",\n    \"headerImageUrl\": \"https://1.bp.blogspot.com/-pWCVY7BR-z8/YQAzb9zCZsI/AAAAAAAAQzY/2-OetxLvjOUYhHlTFJNw5JSm_BVjkI0VwCLcBGAsYHQ/s0/Untitled.png\",\n    \"publishDate\": \"2021-07-27T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"12\"\n    ],\n    \"authors\": [\n      \"10\"\n    ]\n  },\n  {\n    \"id\": \"294\",\n    \"title\": \"Identify performance bottlenecks using system trace\",\n    \"content\": \"System trace profiling within Android Studio with a detailed walkthrough of app startup performance.\",\n    \"url\": \"https://www.youtube.com/watch?v=aUrqx9AnDUg\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/aUrqx9AnDUg/hqdefault.jpg\",\n    \"publishDate\": \"2021-07-25T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"7\",\n      \"5\"\n    ],\n    \"authors\": [\n      \"37\"\n    ]\n  },\n  {\n    \"id\": \"295\",\n    \"title\": \"Testing in Compose\",\n    \"content\": \"ADB released episode #171, part of our continuing series on Jetpack Compose. In this episode, Nick and Romain are joined by Filip Pavlis, Jelle Fresen & Jose Alcérreca to talk about Testing in Compose. They discuss how Compose’s testing APIs were developed hand-in-hand with the UI toolkit, making them more deterministic and opening up new possibilities like manipulating time. They go on to discuss the semantics tree, interop testing, screenshot testing and the possibilities for host-side testing.\",\n    \"url\": \"https://adbackstage.libsyn.com/episode-171-compose-testing\",\n    \"headerImageUrl\": \"http://assets.libsyn.com/content/108505820?height=250&width=250&overlay=true\",\n    \"publishDate\": \"2021-06-29T23:00:00.000Z\",\n    \"type\": \"Podcast 🎙\",\n    \"topics\": [\n      \"3\",\n      \"6\"\n    ],\n    \"authors\": [\n      \"54\"\n    ]\n  },\n  {\n    \"id\": \"296\",\n    \"title\": \"DataStore reached release candidate status\",\n    \"content\": \"DataStore has reached release candidate status meaning the 1.0 stable release is right around the corner!\",\n    \"url\": \"https://developer.android.com/topic/libraries/architecture/datastore\",\n    \"headerImageUrl\": \"https://developer.android.com/images/social/android-developers.png\",\n    \"publishDate\": \"2021-06-29T23:00:00.000Z\",\n    \"type\": \"Jetpack release 🚀\",\n    \"topics\": [\n      \"9\"\n    ],\n    \"authors\": []\n  },\n  {\n    \"id\": \"297\",\n    \"title\": \"Scope Storage Myths\",\n    \"content\": \"Apps will be required to update their targetSdkVersion to API 30 in the second half of the year. That means your app will be required to work with Scoped Storage. In this blog post, Nicole Borrelli busts some Scope storage myths in a Q&A format.\",\n    \"url\": \"https://medium.com/androiddevelopers/scope-storage-myths-ca6a97d7ff37\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1200/1*csWzYUmYq_1HQsqBWk3OTA.jpeg\",\n    \"publishDate\": \"2021-06-27T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"9\",\n      \"11\"\n    ],\n    \"authors\": [\n      \"55\"\n    ]\n  },\n  {\n    \"id\": \"298\",\n    \"title\": \"Navigation with Multiple back stacks\",\n    \"content\": \"As part of the rercommended Material pattern for bottom-navigation, the Jetpack Navigation library makes it easy to implement navigation with multiple back-stacks\",\n    \"url\": \"https://medium.com/androiddevelopers/navigation-multiple-back-stacks-6c67ba41952f\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/1*v7S7LKg4TlrMRlneeP224Q.jpeg\",\n    \"publishDate\": \"2021-06-14T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": [\n      \"25\"\n    ]\n  },\n  {\n    \"id\": \"299\",\n    \"title\": \"Build sophisticated search features with AppSearch\",\n    \"content\": \"AppSearch is an on-device search library which provides high performance and feature-rich full-text search functionality. Learn how to use the new Jetpack AppSearch library for doing high-performance on-device full text searches.\",\n    \"url\": \"https://android-developers.googleblog.com/2021/06/sophisticated-search-with-appsearch-in-jetpack.html\",\n    \"headerImageUrl\": \"https://1.bp.blogspot.com/-PmN4MS50wvo/YMj-HmY4N2I/AAAAAAAAQoQ/5eCx8CU1HgAlFQnQ55IOb_CCVRhe8eGewCLcBGAsYHQ/w1200-h630-p-k-no-nu/AppSearch.jpg\",\n    \"publishDate\": \"2021-06-13T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"8\",\n      \"2\"\n    ],\n    \"authors\": [\n      \"56\"\n    ]\n  },\n  {\n    \"id\": \"300\",\n    \"title\": \"ADB Podcast Episode 167: Jetpack Compose Layout\",\n    \"content\": \"In this second episode of our mini-series on Jetpack Compose (AD/BC), Nick and Romain are joined by Anastasia Soboleva, George Mount and Mihai Popa to talk about Compose’s layout system. They explain how the Compose layout model works and its benefits, introduce common layout composables, discuss how writing your own layout is far simpler than Views, and how you can even animate layout.\",\n    \"url\": \"https://adbackstage.libsyn.com/episode-167-jetpack-compose-layout\",\n    \"headerImageUrl\": \"http://assets.libsyn.com/content/105399023?height=250&width=250&overlay=true\",\n    \"publishDate\": \"2021-06-13T23:00:00.000Z\",\n    \"type\": \"Podcast 🎙\",\n    \"topics\": [\n      \"3\"\n    ],\n    \"authors\": [\n      \"57\"\n    ]\n  },\n  {\n    \"id\": \"301\",\n    \"title\": \"Create an application CoroutineScope using Hilt\",\n    \"content\": \"Learn how to create an applicatioon-scoped CoroutineScope using Hilt, and how to inject it as a dependency.\",\n    \"url\": \"https://medium.com/androiddevelopers/create-an-application-coroutinescope-using-hilt-dd444e721528\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/1*MgDtM-AJmc2m2hg5chkflg.png\",\n    \"publishDate\": \"2021-06-09T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"4\"\n    ],\n    \"authors\": [\n      \"23\"\n    ]\n  },\n  {\n    \"id\": \"302\",\n    \"title\": \"Android 12 Beta 2 Update\",\n    \"content\": \"The second Beta of Android 12 has just been released for you to try. Beta 2 adds new privacy features like the Privacy Dashboard and continues our work of refining the release.\",\n    \"url\": \"https://android-developers.googleblog.com/2021/06/android-12-beta-2-update.html\",\n    \"headerImageUrl\": \"https://1.bp.blogspot.com/-tLt-TVPqpjA/YKMRwRPMfjI/AAAAAAAAQik/JNtMesFZ2i87RyBACHAVEC14CvcU7G__wCLcBGAsYHQ/w1200-h630-p-k-no-nu/Screen%2BShot%2B2021-05-17%2Bat%2B9.00.30%2BPM.png\",\n    \"publishDate\": \"2021-06-08T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"13\"\n    ],\n    \"authors\": [\n      \"14\"\n    ]\n  },\n  {\n    \"id\": \"303\",\n    \"title\": \"Top 3 things in Android 12  | Android @ Google I/O '21\",\n    \"content\": \"Did you miss the latest in Android 12 at Google I/O 2021? Android Software Engineer Chet Haase will recap the top three themes in Android 12 from this year’s Google I/O!\",\n    \"url\": \"https://www.youtube.com/watch?v=tvf1wmD5H0M\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/tvf1wmD5H0M/maxresdefault.jpg\",\n    \"publishDate\": \"2021-06-08T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"13\"\n    ],\n    \"authors\": [\n      \"31\"\n    ]\n  },\n  {\n    \"id\": \"304\",\n    \"title\": \"ADB Podcast Episode 166: Security Deposit\",\n    \"content\": \"In this episode, Chad and Jeff from the Android Security team join Tor and Romain to talk about… security. They explain what the platform does to help preserve user trust and device integrity, why it sometimes means restricting existing APIs, and touch on what apps can do or should worry about.\",\n    \"url\": \"http://adbackstage.libsyn.com/episode-166-security-deposit\",\n    \"headerImageUrl\": \"http://assets.libsyn.com/show/332855?height=250&width=250&overlay=true\",\n    \"publishDate\": \"2021-06-07T23:00:00.000Z\",\n    \"type\": \"Podcast 🎙\",\n    \"topics\": [\n      \"11\"\n    ],\n    \"authors\": [\n      \"32\"\n    ]\n  },\n  {\n    \"id\": \"305\",\n    \"title\": \"Multiple Back Stacks\",\n    \"content\": \"A deep dive into multiple back stacks and some of the work it took to make this feature happen in Fragments and Navigation\",\n    \"url\": \"https://medium.com/androiddevelopers/multiple-back-stacks-b714d974f134\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/1*5-lbc-YBJlZnxVFPvNMPAQ.png\",\n    \"publishDate\": \"2021-06-06T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": [\n      \"58\"\n    ]\n  },\n  {\n    \"id\": \"306\",\n    \"title\": \"Building across devices | Android @ Google I/O '21\",\n    \"content\": \"Did you miss the latest in Building across screens at Google I/O 2021? Product Manager Diana Wong will recap the top three announcements from this year’s Google I/O!\",\n    \"url\": \"https://www.youtube.com/watch?v=O5oRiIUk_F4\",\n    \"headerImageUrl\": \"https://i.ytimg.com/vi/O5oRiIUk_F4/maxresdefault.jpg\",\n    \"publishDate\": \"2021-06-02T23:00:00.000Z\",\n    \"type\": \"Video 📺\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": [\n      \"59\"\n    ]\n  },\n  {\n    \"id\": \"307\",\n    \"title\": \"Navigation in Feature Modules\",\n    \"content\": \"Feature modules delivered with Play Feature delivery at not downloadedd at install time, but only when the app requestss them. Learn how to use the dynamic features navigation library to include the graph from the feature module.\",\n    \"url\": \"https://medium.com/androiddevelopers/navigation-in-feature-modules-322ac3d79334\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/1*v7S7LKg4TlrMRlneeP224Q.jpeg\",\n    \"publishDate\": \"2021-06-01T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": [\n      \"25\"\n    ]\n  },\n  {\n    \"id\": \"308\",\n    \"title\": \"ADB Podcast Episode 165: Material Witnesses\",\n    \"content\": \"In this episode, Chet and Romain chattedd with Hunter and Nick from the Material Design team about recent additions and improvements to the Material Design Component libraries: transitions, motion theming, Compose, large screens support and guidance, etc.\",\n    \"url\": \"http://adbackstage.libsyn.com/episode-165-material-witnesses\",\n    \"headerImageUrl\": \"http://assets.libsyn.com/show/332855?height=250&width=250&overlay=true\",\n    \"publishDate\": \"2021-06-01T23:00:00.000Z\",\n    \"type\": \"Podcast 🎙\",\n    \"topics\": [\n      \"2\"\n    ],\n    \"authors\": [\n      \"31\"\n    ]\n  },\n  {\n    \"id\": \"309\",\n    \"title\": \"Grow Your Indie Game with Help From Google Play\",\n    \"content\": \"Google Play is opening submissions for two of our annual developer programs - the Indie Games Accelerator and the Indie Games Festival. These programs are designed to help small games studios grow on Google Play, no matter what stage they are in\",\n    \"url\": \"https://developers.googleblog.com/2021/06/grow-your-indie-game-with-help-from-google-play.html\",\n    \"headerImageUrl\": \"https://1.bp.blogspot.com/-MNEblg7_8fA/YK7lludSxJI/AAAAAAAAKQM/_YIT15giTk42oPXWIhK6l2FBVt5PCFKTwCLcBGAsYHQ/w1200-h630-p-k-no-nu/Joint_Announcement_Android%2BDevelopers%2BBlog_Header_1200x600%2B%25282%2529.png\",\n    \"publishDate\": \"2021-05-31T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"12\",\n      \"17\"\n    ],\n    \"authors\": [\n      \"60\"\n    ]\n  },\n  {\n    \"id\": \"310\",\n    \"title\": \"Untrusted Touch Events in Android\",\n    \"content\": \"Android 12 prevents touch events from being deliverred if these touches first pass through a window from a different app to ensure users can see what they are interacting with. Learn about alternatives, to see if your app will be affected and how you can test to see if your app will be impacted.\",\n    \"url\": \"https://medium.com/androiddevelopers/untrusted-touch-events-2c0e0b9c374c\",\n    \"headerImageUrl\": \"https://miro.medium.com/max/1400/1*lvwe7v_bcNsNXI_7ltFkJA.jpeg\",\n    \"publishDate\": \"2021-05-25T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"13\"\n    ],\n    \"authors\": [\n      \"15\"\n    ]\n  },\n  {\n    \"id\": \"311\",\n    \"title\": \"Android @ Google I/O: 3 things to know in Modern Android Development\",\n    \"content\": \"This year’s Google I/O brought lots of updates for Modern Android Development. Learn about the top 3 things you should know.\",\n    \"url\": \"https://android-developers.googleblog.com/2021/05/mad-spotlight.html\",\n    \"headerImageUrl\": \"https://1.bp.blogspot.com/-8cqMFObMeko/YK5RbJ7Yr_I/AAAAAAAAQkw/Iw4_hRZwa7QD1CmVGnZUZ4NjYowXZadTgCLcBGAsYHQ/w1200-h630-p-k-no-nu/Android_PostIO_blog-MAD.png\",\n    \"publishDate\": \"2021-05-24T23:00:00.000Z\",\n    \"type\": \"Article 📚\",\n    \"topics\": [\n      \"1\"\n    ],\n    \"authors\": [\n      \"61\"\n    ]\n  }\n]"
  },
  {
    "path": "core/network/src/main/assets/topics.json",
    "content": "[\n  {\n    \"id\": \"1\",\n    \"name\": \"Headlines\",\n    \"shortDescription\": \"News you'll definitely be interested in\",\n    \"longDescription\": \"The latest events and announcements from the world of Android development.\",\n    \"imageUrl\": \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Headlines.svg?alt=media&token=506faab0-617a-4668-9e63-4a2fb996603f\",\n    \"url\": \"\"\n  },\n  {\n    \"id\": \"2\",\n    \"name\": \"UI\",\n    \"shortDescription\": \"not including Compose\",\n    \"longDescription\": \"Stay up to date on Material Design, Navigation, Text, Paging, Compose, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets and much more!\\n\\nTo get Compose specific news, make sure you also follow the Compose topic. \",\n    \"imageUrl\": \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_UI.svg?alt=media&token=0ee1842b-12e8-435f-87ba-a5bb02c47594\",\n    \"url\": \"\"\n  },\n  {\n    \"id\": \"3\",\n    \"name\": \"Compose\",\n    \"shortDescription\": \"\",\n    \"longDescription\": \"All the latest and greatest news on Jetpack Compose - Android’s modern toolkit for building native user interfaces.\",\n    \"imageUrl\": \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Compose.svg?alt=media&token=9f0228e8-fdf2-45ee-9fd0-7e51fda23b48\",\n    \"url\": \"\"\n  },\n  {\n    \"id\": \"4\",\n    \"name\": \"Architecture\",\n    \"shortDescription\": \"\",\n    \"longDescription\": \"Stay up-to-date with Android architecture best practices including scalability and modularization. \",\n    \"imageUrl\": \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Architecture.svg?alt=media&token=e69ed228-fa91-49ae-9017-c8b7331f4269\",\n    \"url\": \"\"\n  },\n  {\n    \"id\": \"5\",\n    \"name\": \"Android Studio & Tools\",\n    \"shortDescription\": \"\",\n    \"longDescription\": \"The latest news on Android development tools, including Android Studio, Gradle, device emulators, debugging tools and more.\",\n    \"imageUrl\": \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Android-Studio.svg?alt=media&token=b28b82dc-5aa1-4098-9eff-deb04636d3ac\",\n    \"url\": \"\"\n  },\n  {\n    \"id\": \"6\",\n    \"name\": \"Testing\",\n    \"shortDescription\": \"\",\n    \"longDescription\": \"The latest news on testing, including unit and UI testing, and continuous integration.  \",\n    \"imageUrl\": \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Testing.svg?alt=media&token=a11533c4-7cc8-4b11-91a3-806158ebf428\",\n    \"url\": \"\"\n  },\n  {\n    \"id\": \"7\",\n    \"name\": \"Performance\",\n    \"shortDescription\": \"\",\n    \"longDescription\": \"Up-to-date content on optimizing your app performance, including profiling, tracing and jank avoidance.\",\n    \"imageUrl\": \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Performance.svg?alt=media&token=558fdf02-1918-4527-b13f-323db67e31cc\",\n    \"url\": \"\"\n  },\n  {\n    \"id\": \"8\",\n    \"name\": \"New APIs & Libraries\",\n    \"shortDescription\": \"\",\n    \"longDescription\": \"Stay up-to-date with new APIs & library releases, including Jetpack.\",\n    \"imageUrl\": \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_New-APIs-_-Libraries.svg?alt=media&token=8efd12df-6dd9-4b1b-81fd-017a49a866ac\",\n    \"url\": \"\"\n  },\n  {\n    \"id\": \"9\",\n    \"name\": \"Data Storage\",\n    \"shortDescription\": \"\",\n    \"longDescription\": \"Everything to do with data storage, including Room and DataStore.\",\n    \"imageUrl\": \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Data-Storage.svg?alt=media&token=c9f78039-f371-4ce1-ba82-2c0c1e20d180\",\n    \"url\": \"\"\n  },\n  {\n    \"id\": \"10\",\n    \"name\": \"Kotlin\",\n    \"shortDescription\": \"\",\n    \"longDescription\": \"New language features and guidance for getting the best out of Kotlin on Android. \",\n    \"imageUrl\": \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Kotlin.svg?alt=media&token=bdc73380-e80d-47df-8954-d9b61cccacd2\",\n    \"url\": \"\"\n  },\n  {\n    \"id\": \"11\",\n    \"name\": \"Privacy & Security\",\n    \"shortDescription\": \"\",\n    \"longDescription\": \"The latest news on security best practices, APIs and libraries.\",\n    \"imageUrl\": \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Privacy-_-Security.svg?alt=media&token=6232fd17-c1cc-43b3-bf70-a734323fa6df\",\n    \"url\": \"\"\n  },\n  {\n    \"id\": \"12\",\n    \"name\": \"Publishing & Distribution\",\n    \"shortDescription\": \"\",\n    \"longDescription\": \"Everything to do with publishing and distributing your app, including Google Play.\",\n    \"imageUrl\": \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Publishing-_-Distribution.svg?alt=media&token=64a5aeaf-269a-479d-8a44-29f59d337dbf\",\n    \"url\": \"\"\n  },\n  {\n    \"id\": \"13\",\n    \"name\": \"Platform & Releases\",\n    \"shortDescription\": \"\",\n    \"longDescription\": \"Stay up-to-date with the latest Android releases and features.\",\n    \"imageUrl\": \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Platform-_-Releases.svg?alt=media&token=ff6d7a38-5205-4a51-8b6a-721e665dc515\",\n    \"url\": \"\"\n  },\n  {\n    \"id\": \"14\",\n    \"name\": \"Accessibility\",\n    \"shortDescription\": \"\",\n    \"longDescription\": \"The latest news on accessibility features and services, helping you to improve your app's usability, particularly for users with disabilities.\",\n    \"imageUrl\": \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Accessibility.svg?alt=media&token=5b783a03-dd3b-4d0c-9e0c-16ae8350295f\",\n    \"url\": \"\"\n  },\n  {\n    \"id\": \"15\",\n    \"name\": \"Android Auto\",\n    \"shortDescription\": \"\",\n    \"longDescription\": \"The latest news on Android Automotive OS and Android Auto.\",\n    \"imageUrl\": \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Android-Auto.svg?alt=media&token=56453754-14a5-4953-b596-66d63c56c196\",\n    \"url\": \"\"\n  },\n  {\n    \"id\": \"16\",\n    \"name\": \"Android TV\",\n    \"shortDescription\": \"\",\n    \"longDescription\": \"Stay up-to-date on everything to do with building apps for Android TV.\",\n    \"imageUrl\": \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Android-TV.svg?alt=media&token=a78ca0df-f1ba-44a6-a89d-3912c82ef661\",\n    \"url\": \"\"\n  },\n  {\n    \"id\": \"17\",\n    \"name\": \"Games\",\n    \"shortDescription\": \"\",\n    \"longDescription\": \"The latest news on Android game development.\",\n    \"imageUrl\": \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Games.svg?alt=media&token=4effa537-cc42-4d7f-b6bd-f1f14568db07\",\n    \"url\": \"\"\n  },\n  {\n    \"id\": \"18\",\n    \"name\": \"Camera & Media\",\n    \"shortDescription\": \"\",\n    \"longDescription\": \"The latest news on capturing and playing media on Android, including the Camera and Media APIs. \",\n    \"imageUrl\": \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Camera-_-Media.svg?alt=media&token=73adea20-20d4-4f4c-8f3b-eb47c1097496\",\n    \"url\": \"\"\n  },\n  {\n    \"id\": \"19\",\n    \"name\": \"Wear OS\",\n    \"shortDescription\": \"\",\n    \"longDescription\": \"The latest news on app development for Wear OS.\",\n    \"imageUrl\": \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Wear.svg?alt=media&token=bd11fe4c-9c92-4536-8ebc-5210f44d09be\",\n    \"url\": \"\"\n  }\n]"
  },
  {
    "path": "core/network/src/main/kotlin/JvmUnitTestDemoAssetManager.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport com.google.samples.apps.nowinandroid.core.network.demo.DemoAssetManager\nimport java.io.File\nimport java.io.InputStream\nimport java.util.Properties\n\n/**\n * This class helps with loading Android `/assets` files, especially when running JVM unit tests.\n * It must remain on the root package for an easier [Class.getResource] with relative paths.\n * @see <a href=\"https://developer.android.com/reference/tools/gradle-api/7.3/com/android/build/api/dsl/UnitTestOptions\">UnitTestOptions</a>\n */\n\ninternal object JvmUnitTestDemoAssetManager : DemoAssetManager {\n    private val config =\n        requireNotNull(javaClass.getResource(\"com/android/tools/test_config.properties\")) {\n            \"\"\"\n            Missing Android resources properties file.\n            Did you forget to enable the feature in the gradle build file?\n            android.testOptions.unitTests.isIncludeAndroidResources = true\n            \"\"\".trimIndent()\n        }\n    private val properties = Properties().apply { config.openStream().use(::load) }\n    private val assets = File(properties[\"android_merged_assets\"].toString())\n\n    override fun open(fileName: String): InputStream = File(assets, fileName).inputStream()\n}\n"
  },
  {
    "path": "core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/NiaNetworkDataSource.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.network\n\nimport com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList\nimport com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource\nimport com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic\n\n/**\n * Interface representing network calls to the NIA backend\n */\ninterface NiaNetworkDataSource {\n    suspend fun getTopics(ids: List<String>? = null): List<NetworkTopic>\n\n    suspend fun getNewsResources(ids: List<String>? = null): List<NetworkNewsResource>\n\n    suspend fun getTopicChangeList(after: Int? = null): List<NetworkChangeList>\n\n    suspend fun getNewsResourceChangeList(after: Int? = null): List<NetworkChangeList>\n}\n"
  },
  {
    "path": "core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/demo/DemoAssetManager.kt",
    "content": "/*\n * Copyright 2024 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.network.demo\n\nimport java.io.InputStream\n\nfun interface DemoAssetManager {\n    fun open(fileName: String): InputStream\n}\n"
  },
  {
    "path": "core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/demo/DemoNiaNetworkDataSource.kt",
    "content": "/*\n * Copyright 2024 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.network.demo\n\nimport JvmUnitTestDemoAssetManager\nimport android.os.Build.VERSION.SDK_INT\nimport android.os.Build.VERSION_CODES.M\nimport com.google.samples.apps.nowinandroid.core.common.network.Dispatcher\nimport com.google.samples.apps.nowinandroid.core.common.network.NiaDispatchers.IO\nimport com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource\nimport com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList\nimport com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource\nimport com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic\nimport kotlinx.coroutines.CoroutineDispatcher\nimport kotlinx.coroutines.withContext\nimport kotlinx.serialization.ExperimentalSerializationApi\nimport kotlinx.serialization.json.Json\nimport kotlinx.serialization.json.decodeFromStream\nimport java.io.BufferedReader\nimport javax.inject.Inject\n\n/**\n * [NiaNetworkDataSource] implementation that provides static news resources to aid development\n */\nclass DemoNiaNetworkDataSource @Inject constructor(\n    @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,\n    private val networkJson: Json,\n    private val assets: DemoAssetManager = JvmUnitTestDemoAssetManager,\n) : NiaNetworkDataSource {\n\n    override suspend fun getTopics(ids: List<String>?): List<NetworkTopic> =\n        getDataFromJsonFile(TOPICS_ASSET)\n\n    override suspend fun getNewsResources(ids: List<String>?): List<NetworkNewsResource> =\n        getDataFromJsonFile(NEWS_ASSET)\n\n    override suspend fun getTopicChangeList(after: Int?): List<NetworkChangeList> =\n        getTopics().mapToChangeList(NetworkTopic::id)\n\n    override suspend fun getNewsResourceChangeList(after: Int?): List<NetworkChangeList> =\n        getNewsResources().mapToChangeList(NetworkNewsResource::id)\n\n    /**\n     * Get data from the given JSON [fileName].\n     */\n    @OptIn(ExperimentalSerializationApi::class)\n    private suspend inline fun <reified T> getDataFromJsonFile(fileName: String): List<T> =\n        withContext(ioDispatcher) {\n            assets.open(fileName).use { inputStream ->\n                if (SDK_INT <= M) {\n                    /**\n                     * On API 23 (M) and below we must use a workaround to avoid an exception being\n                     * thrown during deserialization. See:\n                     * https://github.com/Kotlin/kotlinx.serialization/issues/2457#issuecomment-1786923342\n                     */\n                    inputStream.bufferedReader().use(BufferedReader::readText)\n                        .let(networkJson::decodeFromString)\n                } else {\n                    networkJson.decodeFromStream(inputStream)\n                }\n            }\n        }\n\n    companion object {\n        private const val NEWS_ASSET = \"news.json\"\n        private const val TOPICS_ASSET = \"topics.json\"\n    }\n}\n\n/**\n * Converts a list of [T] to change list of all the items in it where [idGetter] defines the\n * [NetworkChangeList.id]\n */\nprivate fun <T> List<T>.mapToChangeList(\n    idGetter: (T) -> String,\n) = mapIndexed { index, item ->\n    NetworkChangeList(\n        id = idGetter(item),\n        changeListVersion = index,\n        isDelete = false,\n    )\n}\n"
  },
  {
    "path": "core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/di/NetworkModule.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.network.di\n\nimport android.content.Context\nimport androidx.tracing.trace\nimport coil.ImageLoader\nimport coil.decode.SvgDecoder\nimport coil.util.DebugLogger\nimport com.google.samples.apps.nowinandroid.core.network.BuildConfig\nimport com.google.samples.apps.nowinandroid.core.network.demo.DemoAssetManager\nimport dagger.Module\nimport dagger.Provides\nimport dagger.hilt.InstallIn\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport dagger.hilt.components.SingletonComponent\nimport kotlinx.serialization.json.Json\nimport okhttp3.Call\nimport okhttp3.OkHttpClient\nimport okhttp3.logging.HttpLoggingInterceptor\nimport javax.inject.Singleton\n\n@Module\n@InstallIn(SingletonComponent::class)\ninternal object NetworkModule {\n\n    @Provides\n    @Singleton\n    fun providesNetworkJson(): Json = Json {\n        ignoreUnknownKeys = true\n    }\n\n    @Provides\n    @Singleton\n    fun providesDemoAssetManager(\n        @ApplicationContext context: Context,\n    ): DemoAssetManager = DemoAssetManager(context.assets::open)\n\n    @Provides\n    @Singleton\n    fun okHttpCallFactory(): Call.Factory = trace(\"NiaOkHttpClient\") {\n        OkHttpClient.Builder()\n            .addInterceptor(\n                HttpLoggingInterceptor()\n                    .apply {\n                        if (BuildConfig.DEBUG) {\n                            setLevel(HttpLoggingInterceptor.Level.BODY)\n                        }\n                    },\n            )\n            .build()\n    }\n\n    /**\n     * Since we're displaying SVGs in the app, Coil needs an ImageLoader which supports this\n     * format. During Coil's initialization it will call `applicationContext.newImageLoader()` to\n     * obtain an ImageLoader.\n     *\n     * @see <a href=\"https://github.com/coil-kt/coil/blob/main/coil-singleton/src/main/java/coil/Coil.kt\">Coil</a>\n     */\n    @Provides\n    @Singleton\n    fun imageLoader(\n        // We specifically request dagger.Lazy here, so that it's not instantiated from Dagger.\n        okHttpCallFactory: dagger.Lazy<Call.Factory>,\n        @ApplicationContext application: Context,\n    ): ImageLoader = trace(\"NiaImageLoader\") {\n        ImageLoader.Builder(application)\n            .callFactory { okHttpCallFactory.get() }\n            .components { add(SvgDecoder.Factory()) }\n            // Assume most content images are versioned urls\n            // but some problematic images are fetching each time\n            .respectCacheHeaders(false)\n            .apply {\n                if (BuildConfig.DEBUG) {\n                    logger(DebugLogger())\n                }\n            }\n            .build()\n    }\n}\n"
  },
  {
    "path": "core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/model/NetworkChangeList.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.network.model\n\nimport kotlinx.serialization.Serializable\n\n/**\n * Network representation of a change list for a model.\n *\n * Change lists are a representation of a server-side map like data structure of model ids to\n * metadata about that model. In a single change list, a given model id can only show up once.\n */\n@Serializable\ndata class NetworkChangeList(\n    /**\n     * The id of the model that was changed\n     */\n    val id: String,\n    /**\n     * Unique consecutive, monotonically increasing version number in the collection describing\n     * the relative point of change between models in the collection\n     */\n    val changeListVersion: Int,\n    /**\n     * Summarizes the update to the model; whether it was deleted or updated.\n     * Updates include creations.\n     */\n    val isDelete: Boolean,\n)\n"
  },
  {
    "path": "core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/model/NetworkNewsResource.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.network.model\n\nimport android.annotation.SuppressLint\nimport com.google.samples.apps.nowinandroid.core.model.data.NewsResource\nimport kotlinx.datetime.Instant\nimport kotlinx.serialization.Serializable\n\n/**\n * Network representation of [NewsResource] when fetched from /newsresources\n */\n@SuppressLint(\"UnsafeOptInUsageError\")\n@Serializable\ndata class NetworkNewsResource(\n    val id: String,\n    val title: String,\n    val content: String,\n    val url: String,\n    val headerImageUrl: String,\n    val publishDate: Instant,\n    val type: String,\n    val topics: List<String> = emptyList(),\n)\n"
  },
  {
    "path": "core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/model/NetworkTopic.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.network.model\n\nimport com.google.samples.apps.nowinandroid.core.model.data.Topic\nimport kotlinx.serialization.Serializable\n\n/**\n * Network representation of [Topic]\n */\n@Serializable\ndata class NetworkTopic(\n    val id: String,\n    val name: String = \"\",\n    val shortDescription: String = \"\",\n    val longDescription: String = \"\",\n    val url: String = \"\",\n    val imageUrl: String = \"\",\n    val followed: Boolean = false,\n)\n\nfun NetworkTopic.asExternalModel(): Topic =\n    Topic(\n        id = id,\n        name = name,\n        shortDescription = shortDescription,\n        longDescription = longDescription,\n        url = url,\n        imageUrl = imageUrl,\n    )\n"
  },
  {
    "path": "core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/retrofit/RetrofitNiaNetwork.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.network.retrofit\n\nimport androidx.tracing.trace\nimport com.google.samples.apps.nowinandroid.core.network.BuildConfig\nimport com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource\nimport com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList\nimport com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource\nimport com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic\nimport kotlinx.serialization.Serializable\nimport kotlinx.serialization.json.Json\nimport okhttp3.Call\nimport okhttp3.MediaType.Companion.toMediaType\nimport retrofit2.Retrofit\nimport retrofit2.converter.kotlinx.serialization.asConverterFactory\nimport retrofit2.http.GET\nimport retrofit2.http.Query\nimport javax.inject.Inject\nimport javax.inject.Singleton\n\n/**\n * Retrofit API declaration for NIA Network API\n */\nprivate interface RetrofitNiaNetworkApi {\n    @GET(value = \"topics\")\n    suspend fun getTopics(\n        @Query(\"id\") ids: List<String>?,\n    ): NetworkResponse<List<NetworkTopic>>\n\n    @GET(value = \"newsresources\")\n    suspend fun getNewsResources(\n        @Query(\"id\") ids: List<String>?,\n    ): NetworkResponse<List<NetworkNewsResource>>\n\n    @GET(value = \"changelists/topics\")\n    suspend fun getTopicChangeList(\n        @Query(\"after\") after: Int?,\n    ): List<NetworkChangeList>\n\n    @GET(value = \"changelists/newsresources\")\n    suspend fun getNewsResourcesChangeList(\n        @Query(\"after\") after: Int?,\n    ): List<NetworkChangeList>\n}\n\nprivate const val NIA_BASE_URL = BuildConfig.BACKEND_URL\n\n/**\n * Wrapper for data provided from the [NIA_BASE_URL]\n */\n@Serializable\nprivate data class NetworkResponse<T>(\n    val data: T,\n)\n\n/**\n * [Retrofit] backed [NiaNetworkDataSource]\n */\n@Singleton\ninternal class RetrofitNiaNetwork @Inject constructor(\n    networkJson: Json,\n    okhttpCallFactory: dagger.Lazy<Call.Factory>,\n) : NiaNetworkDataSource {\n\n    private val networkApi = trace(\"RetrofitNiaNetwork\") {\n        Retrofit.Builder()\n            .baseUrl(NIA_BASE_URL)\n            // We use callFactory lambda here with dagger.Lazy<Call.Factory>\n            // to prevent initializing OkHttp on the main thread.\n            .callFactory { okhttpCallFactory.get().newCall(it) }\n            .addConverterFactory(\n                networkJson.asConverterFactory(\"application/json\".toMediaType()),\n            )\n            .build()\n            .create(RetrofitNiaNetworkApi::class.java)\n    }\n\n    override suspend fun getTopics(ids: List<String>?): List<NetworkTopic> =\n        networkApi.getTopics(ids = ids).data\n\n    override suspend fun getNewsResources(ids: List<String>?): List<NetworkNewsResource> =\n        networkApi.getNewsResources(ids = ids).data\n\n    override suspend fun getTopicChangeList(after: Int?): List<NetworkChangeList> =\n        networkApi.getTopicChangeList(after = after)\n\n    override suspend fun getNewsResourceChangeList(after: Int?): List<NetworkChangeList> =\n        networkApi.getNewsResourcesChangeList(after = after)\n}\n"
  },
  {
    "path": "core/network/src/prod/kotlin/com/google/samples/apps/nowinandroid/core/network/di/FlavoredNetworkModule.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.network.di\n\nimport com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource\nimport com.google.samples.apps.nowinandroid.core.network.retrofit.RetrofitNiaNetwork\nimport dagger.Binds\nimport dagger.Module\nimport dagger.hilt.InstallIn\nimport dagger.hilt.components.SingletonComponent\n\n@Module\n@InstallIn(SingletonComponent::class)\ninternal interface FlavoredNetworkModule {\n\n    @Binds\n    fun binds(impl: RetrofitNiaNetwork): NiaNetworkDataSource\n}\n"
  },
  {
    "path": "core/network/src/test/kotlin/com/google/samples/apps/nowinandroid/core/network/demo/DemoNiaNetworkDataSourceTest.kt",
    "content": "/*\n * Copyright 2024 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.network.demo\n\nimport JvmUnitTestDemoAssetManager\nimport com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource\nimport com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic\nimport kotlinx.coroutines.test.StandardTestDispatcher\nimport kotlinx.coroutines.test.runTest\nimport kotlinx.datetime.LocalDateTime\nimport kotlinx.datetime.TimeZone\nimport kotlinx.datetime.toInstant\nimport kotlinx.serialization.json.Json\nimport org.junit.Before\nimport org.junit.Test\nimport kotlin.test.assertEquals\n\nclass DemoNiaNetworkDataSourceTest {\n\n    private lateinit var subject: DemoNiaNetworkDataSource\n\n    private val testDispatcher = StandardTestDispatcher()\n\n    @Before\n    fun setUp() {\n        subject = DemoNiaNetworkDataSource(\n            ioDispatcher = testDispatcher,\n            networkJson = Json { ignoreUnknownKeys = true },\n            assets = JvmUnitTestDemoAssetManager,\n        )\n    }\n\n    @Suppress(\"ktlint:standard:max-line-length\")\n    @Test\n    fun testDeserializationOfTopics() = runTest(testDispatcher) {\n        assertEquals(\n            NetworkTopic(\n                id = \"1\",\n                name = \"Headlines\",\n                shortDescription = \"News you'll definitely be interested in\",\n                longDescription = \"The latest events and announcements from the world of Android development.\",\n                url = \"\",\n                imageUrl = \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Headlines.svg?alt=media&token=506faab0-617a-4668-9e63-4a2fb996603f\",\n            ),\n            subject.getTopics().first(),\n        )\n    }\n\n    @Suppress(\"ktlint:standard:max-line-length\")\n    @Test\n    fun testDeserializationOfNewsResources() = runTest(testDispatcher) {\n        assertEquals(\n            NetworkNewsResource(\n                id = \"125\",\n                title = \"Android Basics with Compose\",\n                content = \"We released the first two units of Android Basics with Compose, our first free course that teaches Android Development with Jetpack Compose to anyone; you do not need any prior programming experience other than basic computer literacy to get started. \",\n                url = \"https://android-developers.googleblog.com/2022/05/new-android-basics-with-compose-course.html\",\n                headerImageUrl = \"https://developer.android.com/images/hero-assets/android-basics-compose.svg\",\n                publishDate = LocalDateTime(\n                    year = 2022,\n                    monthNumber = 5,\n                    dayOfMonth = 4,\n                    hour = 23,\n                    minute = 0,\n                    second = 0,\n                    nanosecond = 0,\n                ).toInstant(TimeZone.UTC),\n                type = \"Codelab\",\n                topics = listOf(\"2\", \"3\", \"10\"),\n            ),\n            subject.getNewsResources().find { it.id == \"125\" },\n        )\n    }\n}\n"
  },
  {
    "path": "core/notifications/.gitignore",
    "content": "/build"
  },
  {
    "path": "core/notifications/README.md",
    "content": "# `:core:notifications`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :core\n    direction TB\n    :core:common[common]:::jvm-library\n    :core:model[model]:::jvm-library\n    :core:notifications[notifications]:::android-library\n  end\n\n  :core:notifications -.-> :core:common\n  :core:notifications --> :core:model\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "core/notifications/build.gradle.kts",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nplugins {\n    alias(libs.plugins.nowinandroid.android.library)\n    alias(libs.plugins.nowinandroid.hilt)\n}\n\nandroid {\n    namespace = \"com.google.samples.apps.nowinandroid.core.notifications\"\n}\n\ndependencies {\n    api(projects.core.model)\n\n    implementation(projects.core.common)\n\n    compileOnly(platform(libs.androidx.compose.bom))\n}\n"
  },
  {
    "path": "core/notifications/src/demo/kotlin/com/google/samples/apps/nowinandroid/core/notifications/NotificationsModule.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.notifications\n\nimport dagger.Binds\nimport dagger.Module\nimport dagger.hilt.InstallIn\nimport dagger.hilt.components.SingletonComponent\n\n@Module\n@InstallIn(SingletonComponent::class)\ninternal abstract class NotificationsModule {\n    @Binds\n    abstract fun bindNotifier(\n        notifier: NoOpNotifier,\n    ): Notifier\n}\n"
  },
  {
    "path": "core/notifications/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2023 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <uses-permission android:name=\"android.permission.POST_NOTIFICATIONS\"/>\n</manifest>\n"
  },
  {
    "path": "core/notifications/src/main/kotlin/com/google/samples/apps/nowinandroid/core/notifications/NoOpNotifier.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.notifications\n\nimport com.google.samples.apps.nowinandroid.core.model.data.NewsResource\nimport javax.inject.Inject\n\n/**\n * Implementation of [Notifier] which does nothing. Useful for tests and previews.\n */\ninternal class NoOpNotifier @Inject constructor() : Notifier {\n    override fun postNewsNotifications(newsResources: List<NewsResource>) = Unit\n}\n"
  },
  {
    "path": "core/notifications/src/main/kotlin/com/google/samples/apps/nowinandroid/core/notifications/Notifier.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.notifications\n\nimport com.google.samples.apps.nowinandroid.core.model.data.NewsResource\n\n/**\n * Interface for creating notifications in the app\n */\ninterface Notifier {\n    fun postNewsNotifications(newsResources: List<NewsResource>)\n}\n"
  },
  {
    "path": "core/notifications/src/main/kotlin/com/google/samples/apps/nowinandroid/core/notifications/SystemTrayNotifier.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.notifications\n\nimport android.Manifest.permission\nimport android.app.Notification\nimport android.app.NotificationChannel\nimport android.app.NotificationManager\nimport android.app.PendingIntent\nimport android.content.ComponentName\nimport android.content.Context\nimport android.content.Intent\nimport android.content.pm.PackageManager.PERMISSION_GRANTED\nimport android.os.Build.VERSION\nimport android.os.Build.VERSION_CODES\nimport androidx.core.app.ActivityCompat.checkSelfPermission\nimport androidx.core.app.NotificationCompat\nimport androidx.core.app.NotificationCompat.InboxStyle\nimport androidx.core.app.NotificationManagerCompat\nimport androidx.core.net.toUri\nimport com.google.samples.apps.nowinandroid.core.model.data.NewsResource\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport javax.inject.Inject\nimport javax.inject.Singleton\n\nprivate const val MAX_NUM_NOTIFICATIONS = 5\nprivate const val TARGET_ACTIVITY_NAME = \"com.google.samples.apps.nowinandroid.MainActivity\"\nprivate const val NEWS_NOTIFICATION_REQUEST_CODE = 0\nprivate const val NEWS_NOTIFICATION_SUMMARY_ID = 1\nprivate const val NEWS_NOTIFICATION_CHANNEL_ID = \"\"\nprivate const val NEWS_NOTIFICATION_GROUP = \"NEWS_NOTIFICATIONS\"\nprivate const val DEEP_LINK_SCHEME_AND_HOST = \"https://www.nowinandroid.apps.samples.google.com\"\nprivate const val DEEP_LINK_FOR_YOU_PATH = \"foryou\"\nprivate const val DEEP_LINK_BASE_PATH = \"$DEEP_LINK_SCHEME_AND_HOST/$DEEP_LINK_FOR_YOU_PATH\"\nconst val DEEP_LINK_NEWS_RESOURCE_ID_KEY = \"linkedNewsResourceId\"\nconst val DEEP_LINK_URI_PATTERN = \"$DEEP_LINK_BASE_PATH/{$DEEP_LINK_NEWS_RESOURCE_ID_KEY}\"\n\n/**\n * Implementation of [Notifier] that displays notifications in the system tray.\n */\n@Singleton\ninternal class SystemTrayNotifier @Inject constructor(\n    @ApplicationContext private val context: Context,\n) : Notifier {\n\n    override fun postNewsNotifications(\n        newsResources: List<NewsResource>,\n    ) = with(context) {\n        if (checkSelfPermission(this, permission.POST_NOTIFICATIONS) != PERMISSION_GRANTED) {\n            return\n        }\n\n        val truncatedNewsResources = newsResources.take(MAX_NUM_NOTIFICATIONS)\n\n        val newsNotifications = truncatedNewsResources.map { newsResource ->\n            createNewsNotification {\n                setSmallIcon(R.drawable.core_notifications_ic_nia_notification)\n                    .setContentTitle(newsResource.title)\n                    .setContentText(newsResource.content)\n                    .setContentIntent(newsPendingIntent(newsResource))\n                    .setGroup(NEWS_NOTIFICATION_GROUP)\n                    .setAutoCancel(true)\n            }\n        }\n        val summaryNotification = createNewsNotification {\n            val title = getString(\n                R.string.core_notifications_news_notification_group_summary,\n                truncatedNewsResources.size,\n            )\n            setContentTitle(title)\n                .setContentText(title)\n                .setSmallIcon(R.drawable.core_notifications_ic_nia_notification)\n                // Build summary info into InboxStyle template.\n                .setStyle(newsNotificationStyle(truncatedNewsResources, title))\n                .setGroup(NEWS_NOTIFICATION_GROUP)\n                .setGroupSummary(true)\n                .setAutoCancel(true)\n                .build()\n        }\n\n        // Send the notifications\n        val notificationManager = NotificationManagerCompat.from(this)\n        newsNotifications.forEachIndexed { index, notification ->\n            notificationManager.notify(\n                truncatedNewsResources[index].id.hashCode(),\n                notification,\n            )\n        }\n        notificationManager.notify(NEWS_NOTIFICATION_SUMMARY_ID, summaryNotification)\n    }\n\n    /**\n     * Creates an inbox style summary notification for news updates\n     */\n    private fun newsNotificationStyle(\n        newsResources: List<NewsResource>,\n        title: String,\n    ): InboxStyle = newsResources\n        .fold(InboxStyle()) { inboxStyle, newsResource -> inboxStyle.addLine(newsResource.title) }\n        .setBigContentTitle(title)\n        .setSummaryText(title)\n}\n\n/**\n * Creates a notification for configured for news updates\n */\nprivate fun Context.createNewsNotification(\n    block: NotificationCompat.Builder.() -> Unit,\n): Notification {\n    ensureNotificationChannelExists()\n    return NotificationCompat.Builder(\n        this,\n        NEWS_NOTIFICATION_CHANNEL_ID,\n    )\n        .setPriority(NotificationCompat.PRIORITY_DEFAULT)\n        .apply(block)\n        .build()\n}\n\n/**\n * Ensures that a notification channel is present if applicable\n */\nprivate fun Context.ensureNotificationChannelExists() {\n    if (VERSION.SDK_INT < VERSION_CODES.O) return\n\n    val channel = NotificationChannel(\n        NEWS_NOTIFICATION_CHANNEL_ID,\n        getString(R.string.core_notifications_news_notification_channel_name),\n        NotificationManager.IMPORTANCE_DEFAULT,\n    ).apply {\n        description = getString(R.string.core_notifications_news_notification_channel_description)\n    }\n    // Register the channel with the system\n    NotificationManagerCompat.from(this).createNotificationChannel(channel)\n}\n\nprivate fun Context.newsPendingIntent(\n    newsResource: NewsResource,\n): PendingIntent? = PendingIntent.getActivity(\n    this,\n    NEWS_NOTIFICATION_REQUEST_CODE,\n    Intent().apply {\n        action = Intent.ACTION_VIEW\n        data = newsResource.newsDeepLinkUri()\n        component = ComponentName(\n            packageName,\n            TARGET_ACTIVITY_NAME,\n        )\n    },\n    PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,\n)\n\nprivate fun NewsResource.newsDeepLinkUri() = \"$DEEP_LINK_BASE_PATH/$id\".toUri()\n"
  },
  {
    "path": "core/notifications/src/main/res/drawable-anydpi-v24/core_notifications_ic_nia_notification.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n    android:viewportWidth=\"24\"\n    android:viewportHeight=\"24\"\n    android:tint=\"#FFFFFF\">\n  <group android:scaleX=\"0.92\"\n      android:scaleY=\"0.92\"\n      android:translateX=\"0.96\"\n      android:translateY=\"0.96\">\n      <path\n          android:pathData=\"M17.6,11.48 L19.44,8.3a0.63,0.63 0,0 0,-1.09 -0.63l-1.88,3.24a11.43,11.43 0,0 0,-8.94 0L5.65,7.67a0.63,0.63 0,0 0,-1.09 0.63L6.4,11.48A10.81,10.81 0,0 0,1 20L23,20A10.81,10.81 0,0 0,17.6 11.48ZM7,17.25A1.25,1.25 0,1 1,8.25 16,1.25 1.25,0 0,1 7,17.25ZM17,17.25A1.25,1.25 0,1 1,18.25 16,1.25 1.25,0 0,1 17,17.25Z\"\n          android:fillColor=\"#FF000000\"/>\n  </group>\n</vector>\n"
  },
  {
    "path": "core/notifications/src/main/res/values/strings.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<resources>\n    <string name=\"core_notifications_news_notification_channel_name\">News updates</string>\n    <string name=\"core_notifications_news_notification_channel_description\">The latest updates on what\\'s new in Android</string>\n    <string name=\"core_notifications_news_notification_group_summary\">%1$d news updates</string>\n</resources>\n"
  },
  {
    "path": "core/notifications/src/prod/kotlin/com/google/samples/apps/nowinandroid/core/notifications/NotificationsModule.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.notifications\n\nimport dagger.Binds\nimport dagger.Module\nimport dagger.hilt.InstallIn\nimport dagger.hilt.components.SingletonComponent\n\n@Module\n@InstallIn(SingletonComponent::class)\ninternal abstract class NotificationsModule {\n    @Binds\n    abstract fun bindNotifier(\n        notifier: SystemTrayNotifier,\n    ): Notifier\n}\n"
  },
  {
    "path": "core/screenshot-testing/.gitignore",
    "content": "/build"
  },
  {
    "path": "core/screenshot-testing/README.md",
    "content": "# `:core:screenshot-testing`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :core\n    direction TB\n    :core:designsystem[designsystem]:::android-library\n    :core:screenshot-testing[screenshot-testing]:::android-library\n  end\n\n  :core:screenshot-testing -.-> :core:designsystem\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "core/screenshot-testing/build.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nplugins {\n    alias(libs.plugins.nowinandroid.android.library)\n    alias(libs.plugins.nowinandroid.android.library.compose)\n    alias(libs.plugins.nowinandroid.hilt)\n}\n\nandroid {\n    namespace = \"com.google.samples.apps.nowinandroid.core.screenshottesting\"\n}\n\ndependencies {\n    api(libs.bundles.androidx.compose.ui.test)\n    api(libs.roborazzi)\n    api(libs.roborazzi.accessibility.check)\n    implementation(libs.androidx.compose.ui.test)\n    implementation(libs.androidx.activity.compose)\n    implementation(libs.robolectric)\n    implementation(projects.core.designsystem)\n}\n"
  },
  {
    "path": "core/screenshot-testing/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <!--\n    This theme is used by ComposeTestRules\n    Use a no-action-bar theme to prevent overlapping with the action bar during tests.\n    Theme_Material_Light_NoActionBar is the base theme used by the production app.\n    -->\n    <application android:theme=\"@android:style/Theme.Material.NoActionBar\" />\n</manifest>\n"
  },
  {
    "path": "core/screenshot-testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/ScreenshotHelper.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n@file:OptIn(ExperimentalRoborazziApi::class)\n\npackage com.google.samples.apps.nowinandroid.core.testing.util\n\nimport android.graphics.Bitmap.CompressFormat.PNG\nimport androidx.activity.ComponentActivity\nimport androidx.activity.compose.setContent\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.CompositionLocalProvider\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.key\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.platform.LocalInspectionMode\nimport androidx.compose.ui.test.DarkMode\nimport androidx.compose.ui.test.DeviceConfigurationOverride\nimport androidx.compose.ui.test.junit4.AndroidComposeTestRule\nimport androidx.compose.ui.test.onRoot\nimport androidx.test.ext.junit.rules.ActivityScenarioRule\nimport com.github.takahirom.roborazzi.ExperimentalRoborazziApi\nimport com.github.takahirom.roborazzi.RoborazziATFAccessibilityCheckOptions\nimport com.github.takahirom.roborazzi.RoborazziATFAccessibilityChecker\nimport com.github.takahirom.roborazzi.RoborazziATFAccessibilityChecker.CheckLevel\nimport com.github.takahirom.roborazzi.RoborazziOptions\nimport com.github.takahirom.roborazzi.RoborazziOptions.CompareOptions\nimport com.github.takahirom.roborazzi.RoborazziOptions.RecordOptions\nimport com.github.takahirom.roborazzi.captureRoboImage\nimport com.github.takahirom.roborazzi.checkRoboAccessibility\nimport com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckPreset\nimport com.google.android.apps.common.testing.accessibility.framework.AccessibilityViewCheckResult\nimport com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityViewCheckException\nimport com.google.android.apps.common.testing.accessibility.framework.utils.contrast.BitmapImage\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\nimport org.hamcrest.Matcher\nimport org.hamcrest.Matchers\nimport org.robolectric.RuntimeEnvironment\nimport java.io.File\nimport java.io.FileOutputStream\n\nval DefaultRoborazziOptions =\n    RoborazziOptions(\n        // Pixel-perfect matching\n        compareOptions = CompareOptions(changeThreshold = 0f),\n        // Reduce the size of the PNGs\n        recordOptions = RecordOptions(resizeScale = 0.5),\n    )\n\nenum class DefaultTestDevices(val description: String, val spec: String) {\n    PHONE(\"phone\", \"spec:shape=Normal,width=640,height=360,unit=dp,dpi=480\"),\n    FOLDABLE(\"foldable\", \"spec:shape=Normal,width=673,height=841,unit=dp,dpi=480\"),\n    TABLET(\"tablet\", \"spec:shape=Normal,width=1280,height=800,unit=dp,dpi=480\"),\n}\nfun <A : ComponentActivity> AndroidComposeTestRule<ActivityScenarioRule<A>, A>.captureMultiDevice(\n    screenshotName: String,\n    accessibilitySuppressions: Matcher<in AccessibilityViewCheckResult> = Matchers.not(Matchers.anything()),\n    body: @Composable () -> Unit,\n) {\n    DefaultTestDevices.entries.forEach {\n        this.captureForDevice(\n            deviceName = it.description,\n            deviceSpec = it.spec,\n            screenshotName = screenshotName,\n            body = body,\n            accessibilitySuppressions = accessibilitySuppressions,\n        )\n    }\n}\n\nfun <A : ComponentActivity> AndroidComposeTestRule<ActivityScenarioRule<A>, A>.captureForDevice(\n    deviceName: String,\n    deviceSpec: String,\n    screenshotName: String,\n    roborazziOptions: RoborazziOptions = DefaultRoborazziOptions,\n    accessibilitySuppressions: Matcher<in AccessibilityViewCheckResult> = Matchers.not(Matchers.anything()),\n    darkMode: Boolean = false,\n    body: @Composable () -> Unit,\n) {\n    val (width, height, dpi) = extractSpecs(deviceSpec)\n\n    // Set qualifiers from specs\n    RuntimeEnvironment.setQualifiers(\"w${width}dp-h${height}dp-${dpi}dpi\")\n\n    this.activity.setContent {\n        CompositionLocalProvider(\n            LocalInspectionMode provides true,\n        ) {\n            DeviceConfigurationOverride(\n                override = DeviceConfigurationOverride.Companion.DarkMode(darkMode),\n            ) {\n                body()\n            }\n        }\n    }\n\n    // Run Accessibility checks first so logging is included\n    val accessibilityException = try {\n        this.onRoot().checkRoboAccessibility(\n            roborazziATFAccessibilityCheckOptions = RoborazziATFAccessibilityCheckOptions(\n                failureLevel = CheckLevel.Error,\n                checker = RoborazziATFAccessibilityChecker(\n                    preset = AccessibilityCheckPreset.LATEST,\n                    suppressions = accessibilitySuppressions,\n                ),\n            ),\n        )\n        null\n    } catch (e: AccessibilityViewCheckException) {\n        e\n    }\n\n    this.onRoot()\n        .captureRoboImage(\n            \"src/test/screenshots/${screenshotName}_$deviceName.png\",\n            roborazziOptions = roborazziOptions,\n        )\n\n    // Rethrow the Accessibility exception once screenshots have passed\n    if (accessibilityException != null) {\n        accessibilityException.results.forEachIndexed { index, check ->\n            val viewImage = check.viewImage\n            if (viewImage is BitmapImage) {\n                val file = File(\"build/outputs/roborazzi/${screenshotName}_${deviceName}_$index.png\")\n                println(\"Writing check.viewImage to $file\")\n                FileOutputStream(\n                    file,\n                ).use {\n                    viewImage.bitmap.compress(PNG, 100, it)\n                }\n            }\n        }\n\n        throw accessibilityException\n    }\n}\n\n/**\n * Takes six screenshots combining light/dark and default/Android themes and whether dynamic color\n * is enabled.\n */\nfun <A : ComponentActivity> AndroidComposeTestRule<ActivityScenarioRule<A>, A>.captureMultiTheme(\n    name: String,\n    overrideFileName: String? = null,\n    shouldCompareDarkMode: Boolean = true,\n    shouldCompareDynamicColor: Boolean = true,\n    shouldCompareAndroidTheme: Boolean = true,\n    content: @Composable (desc: String) -> Unit,\n) {\n    val darkModeValues = if (shouldCompareDarkMode) listOf(true, false) else listOf(false)\n    val dynamicThemingValues = if (shouldCompareDynamicColor) listOf(true, false) else listOf(false)\n    val androidThemeValues = if (shouldCompareAndroidTheme) listOf(true, false) else listOf(false)\n\n    var darkMode by mutableStateOf(true)\n    var dynamicTheming by mutableStateOf(false)\n    var androidTheme by mutableStateOf(false)\n\n    this.setContent {\n        CompositionLocalProvider(\n            LocalInspectionMode provides true,\n        ) {\n            NiaTheme(\n                androidTheme = androidTheme,\n                darkTheme = darkMode,\n                disableDynamicTheming = !dynamicTheming,\n            ) {\n                // Keying is necessary in some cases (e.g. animations)\n                key(androidTheme, darkMode, dynamicTheming) {\n                    val description = generateDescription(\n                        shouldCompareDarkMode,\n                        darkMode,\n                        shouldCompareAndroidTheme,\n                        androidTheme,\n                        shouldCompareDynamicColor,\n                        dynamicTheming,\n                    )\n                    content(description)\n                }\n            }\n        }\n    }\n\n    // Create permutations\n    darkModeValues.forEach { isDarkMode ->\n        darkMode = isDarkMode\n        val darkModeDesc = if (isDarkMode) \"dark\" else \"light\"\n\n        androidThemeValues.forEach { isAndroidTheme ->\n            androidTheme = isAndroidTheme\n            val androidThemeDesc = if (isAndroidTheme) \"androidTheme\" else \"defaultTheme\"\n\n            dynamicThemingValues.forEach dynamicTheme@{ isDynamicTheming ->\n                // Skip tests with both Android Theme and Dynamic color as they're incompatible.\n                if (isAndroidTheme && isDynamicTheming) return@dynamicTheme\n\n                dynamicTheming = isDynamicTheming\n                val dynamicThemingDesc = if (isDynamicTheming) \"dynamic\" else \"notDynamic\"\n\n                val filename = overrideFileName ?: name\n\n                this.onRoot()\n                    .captureRoboImage(\n                        \"src/test/screenshots/\" +\n                            \"$name/$filename\" +\n                            \"_$darkModeDesc\" +\n                            \"_$androidThemeDesc\" +\n                            \"_$dynamicThemingDesc\" +\n                            \".png\",\n                        roborazziOptions = DefaultRoborazziOptions,\n                    )\n            }\n        }\n    }\n}\n\n@Composable\nprivate fun generateDescription(\n    shouldCompareDarkMode: Boolean,\n    darkMode: Boolean,\n    shouldCompareAndroidTheme: Boolean,\n    androidTheme: Boolean,\n    shouldCompareDynamicColor: Boolean,\n    dynamicTheming: Boolean,\n): String {\n    val description = \"\" +\n        if (shouldCompareDarkMode) {\n            if (darkMode) \"Dark\" else \"Light\"\n        } else {\n            \"\"\n        } +\n        if (shouldCompareAndroidTheme) {\n            if (androidTheme) \" Android\" else \" Default\"\n        } else {\n            \"\"\n        } +\n        if (shouldCompareDynamicColor) {\n            if (dynamicTheming) \" Dynamic\" else \"\"\n        } else {\n            \"\"\n        }\n\n    return description.trim()\n}\n\n/**\n * Extracts some properties from the spec string. Note that this function is not exhaustive.\n */\nprivate fun extractSpecs(deviceSpec: String): TestDeviceSpecs {\n    val specs = deviceSpec.substringAfter(\"spec:\")\n        .split(\",\").map { it.split(\"=\") }.associate { it[0] to it[1] }\n    val width = specs[\"width\"]?.toInt() ?: 640\n    val height = specs[\"height\"]?.toInt() ?: 480\n    val dpi = specs[\"dpi\"]?.toInt() ?: 480\n    return TestDeviceSpecs(width, height, dpi)\n}\n\ndata class TestDeviceSpecs(val width: Int, val height: Int, val dpi: Int)\n"
  },
  {
    "path": "core/testing/.gitignore",
    "content": "/build"
  },
  {
    "path": "core/testing/README.md",
    "content": "# `:core:testing`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :core\n    direction TB\n    :core:analytics[analytics]:::android-library\n    :core:common[common]:::jvm-library\n    :core:data[data]:::android-library\n    :core:database[database]:::android-library\n    :core:datastore[datastore]:::android-library\n    :core:datastore-proto[datastore-proto]:::jvm-library\n    :core:model[model]:::jvm-library\n    :core:network[network]:::android-library\n    :core:notifications[notifications]:::android-library\n    :core:testing[testing]:::android-library\n  end\n\n  :core:data -.-> :core:analytics\n  :core:data --> :core:common\n  :core:data --> :core:database\n  :core:data --> :core:datastore\n  :core:data --> :core:network\n  :core:data -.-> :core:notifications\n  :core:database --> :core:model\n  :core:datastore -.-> :core:common\n  :core:datastore --> :core:datastore-proto\n  :core:datastore --> :core:model\n  :core:network --> :core:common\n  :core:network --> :core:model\n  :core:notifications -.-> :core:common\n  :core:notifications --> :core:model\n  :core:testing --> :core:analytics\n  :core:testing --> :core:common\n  :core:testing --> :core:data\n  :core:testing --> :core:model\n  :core:testing --> :core:notifications\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "core/testing/build.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nplugins {\n    alias(libs.plugins.nowinandroid.android.library)\n    alias(libs.plugins.nowinandroid.hilt)\n}\n\nandroid {\n    namespace = \"com.google.samples.apps.nowinandroid.core.testing\"\n}\n\ndependencies {\n    api(libs.kotlinx.coroutines.test)\n    api(projects.core.analytics)\n    api(projects.core.common)\n    api(projects.core.data)\n    api(projects.core.model)\n    api(projects.core.notifications)\n\n\n    implementation(libs.androidx.test.rules)\n    implementation(libs.hilt.android.testing)\n    implementation(libs.kotlinx.datetime)\n}\n"
  },
  {
    "path": "core/testing/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<manifest />\n"
  },
  {
    "path": "core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/rules/GrantPostNotificationsPermissionRule.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.rules\n\nimport android.Manifest.permission.POST_NOTIFICATIONS\nimport android.os.Build.VERSION.SDK_INT\nimport android.os.Build.VERSION_CODES.TIRAMISU\nimport androidx.test.rule.GrantPermissionRule.grant\nimport org.junit.rules.TestRule\n\n/**\n * [TestRule] granting [POST_NOTIFICATIONS] permission if running on [SDK_INT] greater than [TIRAMISU].\n */\nclass GrantPostNotificationsPermissionRule :\n    TestRule by if (SDK_INT >= TIRAMISU) grant(POST_NOTIFICATIONS) else grant()\n"
  },
  {
    "path": "core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/NiaTestRunner.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.testing\n\nimport android.app.Application\nimport android.content.Context\nimport androidx.test.runner.AndroidJUnitRunner\nimport dagger.hilt.android.testing.HiltTestApplication\n\n/**\n * A custom runner to set up the instrumented application class for tests.\n */\nclass NiaTestRunner : AndroidJUnitRunner() {\n    override fun newApplication(cl: ClassLoader, name: String, context: Context): Application =\n        super.newApplication(cl, HiltTestApplication::class.java.name, context)\n}\n"
  },
  {
    "path": "core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/FollowableTopicTestData.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n@file:Suppress(\"ktlint:standard:max-line-length\")\n\npackage com.google.samples.apps.nowinandroid.core.testing.data\n\nimport com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic\nimport com.google.samples.apps.nowinandroid.core.model.data.Topic\n\nval followableTopicTestData: List<FollowableTopic> = listOf(\n    FollowableTopic(\n        topic = Topic(\n            id = \"2\",\n            name = \"Headlines\",\n            shortDescription = \"News we want everyone to see\",\n            longDescription = \"Stay up to date with the latest events and announcements from Android!\",\n            imageUrl = \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Headlines.svg?alt=media&token=506faab0-617a-4668-9e63-4a2fb996603f\",\n            url = \"\",\n        ),\n        isFollowed = false,\n    ),\n    FollowableTopic(\n        topic = Topic(\n            id = \"3\",\n            name = \"UI\",\n            shortDescription = \"Material Design, Navigation, Text, Paging, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets\",\n            longDescription = \"Learn how to optimize your app's user interface - everything that users can see and interact with. Stay up to date on topics such as Material Design, Navigation, Text, Paging, Compose, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets, and many more!\",\n            imageUrl = \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_UI.svg?alt=media&token=0ee1842b-12e8-435f-87ba-a5bb02c47594\",\n            url = \"\",\n        ),\n        isFollowed = true,\n    ),\n    FollowableTopic(\n        topic = Topic(\n            id = \"4\",\n            name = \"Testing\",\n            shortDescription = \"CI, Espresso, TestLab, etc\",\n            longDescription = \"Testing is an integral part of the app development process. By running tests against your app consistently, you can verify your app's correctness, functional behavior, and usability before you release it publicly. Stay up to date on the latest tricks in CI, Espresso, and Firebase TestLab.\",\n            imageUrl = \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Testing.svg?alt=media&token=a11533c4-7cc8-4b11-91a3-806158ebf428\",\n            url = \"\",\n        ),\n        isFollowed = false,\n    ),\n)\n"
  },
  {
    "path": "core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/NewsResourcesTestData.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n@file:Suppress(\"ktlint:standard:max-line-length\")\n\npackage com.google.samples.apps.nowinandroid.core.testing.data\n\nimport com.google.samples.apps.nowinandroid.core.model.data.NewsResource\nimport kotlinx.datetime.Instant\n\nval newsResourcesTestData: List<NewsResource> = listOf(\n    NewsResource(\n        id = \"1\",\n        title = \"Android Basics with Compose\",\n        content = \"We released the first two units of Android Basics with Compose, our first free course that teaches Android Development with Jetpack Compose to anyone; you do not need any prior programming experience other than basic computer literacy to get started. You’ll learn the fundamentals of programming in Kotlin while building Android apps using Jetpack Compose, Android’s modern toolkit that simplifies and accelerates native UI development. These two units are just the beginning; more will be coming soon. Check out Android Basics with Compose to get started on your Android development journey\",\n        url = \"https://android-developers.googleblog.com/2022/05/new-android-basics-with-compose-course.html\",\n        headerImageUrl = \"https://developer.android.com/images/hero-assets/android-basics-compose.svg\",\n        publishDate = Instant.parse(\"2021-11-09T00:00:00.000Z\"),\n        type = \"Codelab\",\n        topics = listOf(topicsTestData[1]),\n    ),\n    NewsResource(\n        id = \"2\",\n        title = \"Thanks for helping us reach 1M YouTube Subscribers\",\n        content = \"Thank you everyone for following the Now in Android series and everything the \" +\n            \"Android Developers YouTube channel has to offer. During the Android Developer \" +\n            \"Summit, our YouTube channel reached 1 million subscribers! Here’s a small video to \" +\n            \"thank you all.\",\n        url = \"https://youtu.be/-fJ6poHQrjM\",\n        headerImageUrl = \"https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg\",\n        publishDate = Instant.parse(\"2021-11-09T00:00:00.000Z\"),\n        type = \"Video 📺\",\n        topics = listOf(topicsTestData[0], topicsTestData[1]),\n    ),\n    NewsResource(\n        id = \"3\",\n        title = \"Transformations and customisations in the Paging Library\",\n        content = \"A demonstration of different operations that can be performed \" +\n            \"with Paging. Transformations like inserting separators, when to \" +\n            \"create a new pager, and customisation options for consuming \" +\n            \"PagingData.\",\n        url = \"https://youtu.be/ZARz0pjm5YM\",\n        headerImageUrl = \"https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg\",\n        publishDate = Instant.parse(\"2021-11-01T00:00:00.000Z\"),\n        type = \"Video 📺\",\n        topics = listOf(topicsTestData[2]),\n    ),\n    NewsResource(\n        id = \"4\",\n        title = \"New Jetpack Release\",\n        content = \"New Jetpack release includes updates to libraries such as CameraX, Benchmark, and\" +\n            \"more!\",\n        url = \"https://developer.android.com/jetpack/androidx/versions/all-channel\",\n        headerImageUrl = \"\",\n        publishDate = Instant.parse(\"2022-10-01T00:00:00.000Z\"),\n        type = \"\",\n        topics = listOf(topicsTestData[2]),\n    ),\n)\n"
  },
  {
    "path": "core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/TopicsTestData.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n@file:Suppress(\"ktlint:standard:max-line-length\")\n\npackage com.google.samples.apps.nowinandroid.core.testing.data\n\nimport com.google.samples.apps.nowinandroid.core.model.data.Topic\n\nval topicsTestData: List<Topic> = listOf(\n    Topic(\n        id = \"2\",\n        name = \"Headlines\",\n        shortDescription = \"News we want everyone to see\",\n        longDescription = \"Stay up to date with the latest events and announcements from Android!\",\n        imageUrl = \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Headlines.svg?alt=media&token=506faab0-617a-4668-9e63-4a2fb996603f\",\n        url = \"\",\n    ),\n    Topic(\n        id = \"3\",\n        name = \"UI\",\n        shortDescription = \"Material Design, Navigation, Text, Paging, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets\",\n        longDescription = \"Learn how to optimize your app's user interface - everything that users can see and interact with. Stay up to date on topics such as Material Design, Navigation, Text, Paging, Compose, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets, and many more!\",\n        imageUrl = \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_UI.svg?alt=media&token=0ee1842b-12e8-435f-87ba-a5bb02c47594\",\n        url = \"\",\n    ),\n    Topic(\n        id = \"4\",\n        name = \"Testing\",\n        shortDescription = \"CI, Espresso, TestLab, etc\",\n        longDescription = \"Testing is an integral part of the app development process. By running tests against your app consistently, you can verify your app's correctness, functional behavior, and usability before you release it publicly. Stay up to date on the latest tricks in CI, Espresso, and Firebase TestLab.\",\n        imageUrl = \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Testing.svg?alt=media&token=a11533c4-7cc8-4b11-91a3-806158ebf428\",\n        url = \"\",\n    ),\n)\n"
  },
  {
    "path": "core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/UserNewsResourcesTestData.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n@file:Suppress(\"ktlint:standard:max-line-length\")\n\npackage com.google.samples.apps.nowinandroid.core.testing.data\n\nimport com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig\nimport com.google.samples.apps.nowinandroid.core.model.data.NewsResource\nimport com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand\nimport com.google.samples.apps.nowinandroid.core.model.data.UserData\nimport com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource\nimport kotlinx.datetime.Instant\nimport kotlinx.datetime.LocalDateTime\nimport kotlinx.datetime.TimeZone\nimport kotlinx.datetime.toInstant\n\nval userNewsResourcesTestData: List<UserNewsResource> = UserData(\n    bookmarkedNewsResources = setOf(\"1\", \"4\"),\n    viewedNewsResources = setOf(\"1\", \"2\", \"4\"),\n    followedTopics = emptySet(),\n    themeBrand = ThemeBrand.ANDROID,\n    darkThemeConfig = DarkThemeConfig.DARK,\n    shouldHideOnboarding = true,\n    useDynamicColor = false,\n).let { userData ->\n    listOf(\n        UserNewsResource(\n            newsResource = NewsResource(\n                id = \"1\",\n                title = \"Android Basics with Compose\",\n                content = \"We released the first two units of Android Basics with Compose, our first free course that teaches Android Development with Jetpack Compose to anyone; you do not need any prior programming experience other than basic computer literacy to get started. You’ll learn the fundamentals of programming in Kotlin while building Android apps using Jetpack Compose, Android’s modern toolkit that simplifies and accelerates native UI development. These two units are just the beginning; more will be coming soon. Check out Android Basics with Compose to get started on your Android development journey\",\n                url = \"https://android-developers.googleblog.com/2022/05/new-android-basics-with-compose-course.html\",\n                headerImageUrl = \"https://developer.android.com/images/hero-assets/android-basics-compose.svg\",\n                publishDate = LocalDateTime(\n                    year = 2022,\n                    monthNumber = 5,\n                    dayOfMonth = 4,\n                    hour = 23,\n                    minute = 0,\n                    second = 0,\n                    nanosecond = 0,\n                ).toInstant(TimeZone.UTC),\n                type = \"Codelab\",\n                topics = listOf(topicsTestData[2]),\n            ),\n            userData = userData,\n        ),\n        UserNewsResource(\n            newsResource = NewsResource(\n                id = \"2\",\n                title = \"Thanks for helping us reach 1M YouTube Subscribers\",\n                content = \"Thank you everyone for following the Now in Android series and everything the \" +\n                    \"Android Developers YouTube channel has to offer. During the Android Developer \" +\n                    \"Summit, our YouTube channel reached 1 million subscribers! Here’s a small video to \" +\n                    \"thank you all.\",\n                url = \"https://youtu.be/-fJ6poHQrjM\",\n                headerImageUrl = \"https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg\",\n                publishDate = Instant.parse(\"2021-11-09T00:00:00.000Z\"),\n                type = \"Video 📺\",\n                topics = topicsTestData.take(2),\n            ),\n            userData = userData,\n        ),\n        UserNewsResource(\n            newsResource = NewsResource(\n                id = \"3\",\n                title = \"Transformations and customisations in the Paging Library\",\n                content = \"A demonstration of different operations that can be performed \" +\n                    \"with Paging. Transformations like inserting separators, when to \" +\n                    \"create a new pager, and customisation options for consuming \" +\n                    \"PagingData.\",\n                url = \"https://youtu.be/ZARz0pjm5YM\",\n                headerImageUrl = \"https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg\",\n                publishDate = Instant.parse(\"2021-11-01T00:00:00.000Z\"),\n                type = \"Video 📺\",\n                topics = listOf(topicsTestData[2]),\n            ),\n            userData = userData,\n        ),\n        UserNewsResource(\n            newsResource = NewsResource(\n                id = \"4\",\n                title = \"New Jetpack Release\",\n                content = \"New Jetpack release includes updates to libraries such as CameraX, Benchmark, and\" +\n                    \"more!\",\n                url = \"https://developer.android.com/jetpack/androidx/versions/all-channel\",\n                headerImageUrl = \"\",\n                publishDate = Instant.parse(\"2022-10-01T00:00:00.000Z\"),\n                type = \"\",\n                topics = listOf(topicsTestData[2]),\n            ),\n            userData = userData,\n        ),\n    )\n}\n"
  },
  {
    "path": "core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/di/TestDispatcherModule.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.testing.di\n\nimport dagger.Module\nimport dagger.Provides\nimport dagger.hilt.InstallIn\nimport dagger.hilt.components.SingletonComponent\nimport kotlinx.coroutines.test.TestDispatcher\nimport kotlinx.coroutines.test.UnconfinedTestDispatcher\nimport javax.inject.Singleton\n\n@Module\n@InstallIn(SingletonComponent::class)\ninternal object TestDispatcherModule {\n    @Provides\n    @Singleton\n    fun providesTestDispatcher(): TestDispatcher = UnconfinedTestDispatcher()\n}\n"
  },
  {
    "path": "core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/di/TestDispatchersModule.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.testing.di\n\nimport com.google.samples.apps.nowinandroid.core.common.network.Dispatcher\nimport com.google.samples.apps.nowinandroid.core.common.network.NiaDispatchers.Default\nimport com.google.samples.apps.nowinandroid.core.common.network.NiaDispatchers.IO\nimport com.google.samples.apps.nowinandroid.core.common.network.di.DispatchersModule\nimport dagger.Module\nimport dagger.Provides\nimport dagger.hilt.components.SingletonComponent\nimport dagger.hilt.testing.TestInstallIn\nimport kotlinx.coroutines.CoroutineDispatcher\nimport kotlinx.coroutines.test.TestDispatcher\n\n@Module\n@TestInstallIn(\n    components = [SingletonComponent::class],\n    replaces = [DispatchersModule::class],\n)\ninternal object TestDispatchersModule {\n    @Provides\n    @Dispatcher(IO)\n    fun providesIODispatcher(testDispatcher: TestDispatcher): CoroutineDispatcher = testDispatcher\n\n    @Provides\n    @Dispatcher(Default)\n    fun providesDefaultDispatcher(\n        testDispatcher: TestDispatcher,\n    ): CoroutineDispatcher = testDispatcher\n}\n"
  },
  {
    "path": "core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/notifications/TestNotifier.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.testing.notifications\n\nimport com.google.samples.apps.nowinandroid.core.model.data.NewsResource\nimport com.google.samples.apps.nowinandroid.core.notifications.Notifier\n\n/**\n * Aggregates news resources that have been notified for addition\n */\nclass TestNotifier : Notifier {\n\n    private val mutableAddedNewResources = mutableListOf<List<NewsResource>>()\n\n    val addedNewsResources: List<List<NewsResource>> = mutableAddedNewResources\n\n    override fun postNewsNotifications(newsResources: List<NewsResource>) {\n        mutableAddedNewResources.add(newsResources)\n    }\n}\n"
  },
  {
    "path": "core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/repository/TestNewsRepository.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.testing.repository\n\nimport com.google.samples.apps.nowinandroid.core.data.Synchronizer\nimport com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery\nimport com.google.samples.apps.nowinandroid.core.model.data.NewsResource\nimport com.google.samples.apps.nowinandroid.core.model.data.Topic\nimport kotlinx.coroutines.channels.BufferOverflow\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.MutableSharedFlow\nimport kotlinx.coroutines.flow.map\n\nclass TestNewsRepository : NewsRepository {\n\n    /**\n     * The backing hot flow for the list of topics ids for testing.\n     */\n    private val newsResourcesFlow: MutableSharedFlow<List<NewsResource>> =\n        MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)\n\n    override fun getNewsResources(query: NewsResourceQuery): Flow<List<NewsResource>> =\n        newsResourcesFlow.map { newsResources ->\n            var result = newsResources\n            query.filterTopicIds?.let { filterTopicIds ->\n                result = newsResources.filter {\n                    it.topics.map(Topic::id).intersect(filterTopicIds).isNotEmpty()\n                }\n            }\n            query.filterNewsIds?.let { filterNewsIds ->\n                result = newsResources.filter { it.id in filterNewsIds }\n            }\n            result\n        }\n\n    /**\n     * A test-only API to allow controlling the list of news resources from tests.\n     */\n    fun sendNewsResources(newsResources: List<NewsResource>) {\n        newsResourcesFlow.tryEmit(newsResources)\n    }\n\n    override suspend fun syncWith(synchronizer: Synchronizer) = true\n}\n"
  },
  {
    "path": "core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/repository/TestRecentSearchRepository.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.testing.repository\n\nimport com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery\nimport com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.flowOf\n\nclass TestRecentSearchRepository : RecentSearchRepository {\n\n    private val cachedRecentSearches: MutableList<RecentSearchQuery> = mutableListOf()\n\n    override fun getRecentSearchQueries(limit: Int): Flow<List<RecentSearchQuery>> =\n        flowOf(cachedRecentSearches.sortedByDescending { it.queriedDate }.take(limit))\n\n    override suspend fun insertOrReplaceRecentSearch(searchQuery: String) {\n        cachedRecentSearches.add(RecentSearchQuery(searchQuery))\n    }\n\n    override suspend fun clearRecentSearches() = cachedRecentSearches.clear()\n}\n"
  },
  {
    "path": "core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/repository/TestSearchContentsRepository.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.testing.repository\n\nimport com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository\nimport com.google.samples.apps.nowinandroid.core.model.data.NewsResource\nimport com.google.samples.apps.nowinandroid.core.model.data.SearchResult\nimport com.google.samples.apps.nowinandroid.core.model.data.Topic\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.flow.combine\nimport kotlinx.coroutines.flow.update\nimport org.jetbrains.annotations.TestOnly\n\nclass TestSearchContentsRepository : SearchContentsRepository {\n\n    private val cachedTopics = MutableStateFlow(emptyList<Topic>())\n    private val cachedNewsResources = MutableStateFlow(emptyList<NewsResource>())\n\n    override suspend fun populateFtsData() = Unit\n\n    override fun searchContents(searchQuery: String): Flow<SearchResult> =\n        combine(cachedTopics, cachedNewsResources) { topics, news ->\n            SearchResult(\n                topics = topics.filter {\n                    searchQuery in it.name || searchQuery in it.shortDescription || searchQuery in it.longDescription\n                },\n                newsResources = news.filter {\n                    searchQuery in it.content || searchQuery in it.title\n                },\n            )\n        }\n\n    override fun getSearchContentsCount(): Flow<Int> = combine(cachedTopics, cachedNewsResources) { topics, news -> topics.size + news.size }\n\n    @TestOnly\n    fun addTopics(topics: List<Topic>) = cachedTopics.update { it + topics }\n\n    @TestOnly\n    fun addNewsResources(newsResources: List<NewsResource>) =\n        cachedNewsResources.update { it + newsResources }\n}\n"
  },
  {
    "path": "core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/repository/TestTopicsRepository.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.testing.repository\n\nimport com.google.samples.apps.nowinandroid.core.data.Synchronizer\nimport com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository\nimport com.google.samples.apps.nowinandroid.core.model.data.Topic\nimport kotlinx.coroutines.channels.BufferOverflow\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.MutableSharedFlow\nimport kotlinx.coroutines.flow.map\n\nclass TestTopicsRepository : TopicsRepository {\n    /**\n     * The backing hot flow for the list of topics ids for testing.\n     */\n    private val topicsFlow: MutableSharedFlow<List<Topic>> =\n        MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)\n\n    override fun getTopics(): Flow<List<Topic>> = topicsFlow\n\n    override fun getTopic(id: String): Flow<Topic> =\n        topicsFlow.map { topics -> topics.find { it.id == id }!! }\n\n    /**\n     * A test-only API to allow controlling the list of topics from tests.\n     */\n    fun sendTopics(topics: List<Topic>) {\n        topicsFlow.tryEmit(topics)\n    }\n\n    override suspend fun syncWith(synchronizer: Synchronizer) = true\n}\n"
  },
  {
    "path": "core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.testing.repository\n\nimport com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository\nimport com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig\nimport com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand\nimport com.google.samples.apps.nowinandroid.core.model.data.UserData\nimport kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.MutableSharedFlow\nimport kotlinx.coroutines.flow.filterNotNull\n\nval emptyUserData = UserData(\n    bookmarkedNewsResources = emptySet(),\n    viewedNewsResources = emptySet(),\n    followedTopics = emptySet(),\n    themeBrand = ThemeBrand.DEFAULT,\n    darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM,\n    useDynamicColor = false,\n    shouldHideOnboarding = false,\n)\n\nclass TestUserDataRepository : UserDataRepository {\n    /**\n     * The backing hot flow for the list of followed topic ids for testing.\n     */\n    private val _userData = MutableSharedFlow<UserData>(replay = 1, onBufferOverflow = DROP_OLDEST)\n\n    private val currentUserData get() = _userData.replayCache.firstOrNull() ?: emptyUserData\n\n    override val userData: Flow<UserData> = _userData.filterNotNull()\n\n    override suspend fun setFollowedTopicIds(followedTopicIds: Set<String>) {\n        _userData.tryEmit(currentUserData.copy(followedTopics = followedTopicIds))\n    }\n\n    override suspend fun setTopicIdFollowed(followedTopicId: String, followed: Boolean) {\n        currentUserData.let { current ->\n            val followedTopics = if (followed) {\n                current.followedTopics + followedTopicId\n            } else {\n                current.followedTopics - followedTopicId\n            }\n\n            _userData.tryEmit(current.copy(followedTopics = followedTopics))\n        }\n    }\n\n    override suspend fun setNewsResourceBookmarked(newsResourceId: String, bookmarked: Boolean) {\n        currentUserData.let { current ->\n            val bookmarkedNews = if (bookmarked) {\n                current.bookmarkedNewsResources + newsResourceId\n            } else {\n                current.bookmarkedNewsResources - newsResourceId\n            }\n\n            _userData.tryEmit(current.copy(bookmarkedNewsResources = bookmarkedNews))\n        }\n    }\n\n    override suspend fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) {\n        currentUserData.let { current ->\n            _userData.tryEmit(\n                current.copy(\n                    viewedNewsResources =\n                    if (viewed) {\n                        current.viewedNewsResources + newsResourceId\n                    } else {\n                        current.viewedNewsResources - newsResourceId\n                    },\n                ),\n            )\n        }\n    }\n\n    override suspend fun setThemeBrand(themeBrand: ThemeBrand) {\n        currentUserData.let { current ->\n            _userData.tryEmit(current.copy(themeBrand = themeBrand))\n        }\n    }\n\n    override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) {\n        currentUserData.let { current ->\n            _userData.tryEmit(current.copy(darkThemeConfig = darkThemeConfig))\n        }\n    }\n\n    override suspend fun setDynamicColorPreference(useDynamicColor: Boolean) {\n        currentUserData.let { current ->\n            _userData.tryEmit(current.copy(useDynamicColor = useDynamicColor))\n        }\n    }\n\n    override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) {\n        currentUserData.let { current ->\n            _userData.tryEmit(current.copy(shouldHideOnboarding = shouldHideOnboarding))\n        }\n    }\n\n    /**\n     * A test-only API to allow setting of user data directly.\n     */\n    fun setUserData(userData: UserData) {\n        _userData.tryEmit(userData)\n    }\n}\n"
  },
  {
    "path": "core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/MainDispatcherRule.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.testing.util\n\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.test.TestDispatcher\nimport kotlinx.coroutines.test.UnconfinedTestDispatcher\nimport kotlinx.coroutines.test.resetMain\nimport kotlinx.coroutines.test.setMain\nimport org.junit.rules.TestRule\nimport org.junit.rules.TestWatcher\nimport org.junit.runner.Description\n\n/**\n * A JUnit [TestRule] that sets the Main dispatcher to [testDispatcher]\n * for the duration of the test.\n */\nclass MainDispatcherRule(\n    private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),\n) : TestWatcher() {\n    override fun starting(description: Description) = Dispatchers.setMain(testDispatcher)\n\n    override fun finished(description: Description) = Dispatchers.resetMain()\n}\n"
  },
  {
    "path": "core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/TestAnalyticsHelper.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.testing.util\n\nimport com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent\nimport com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper\n\nclass TestAnalyticsHelper : AnalyticsHelper {\n\n    private val events = mutableListOf<AnalyticsEvent>()\n    override fun logEvent(event: AnalyticsEvent) {\n        events.add(event)\n    }\n\n    fun hasLogged(event: AnalyticsEvent) = event in events\n}\n"
  },
  {
    "path": "core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/TestNetworkMonitor.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.testing.util\n\nimport com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.MutableStateFlow\n\nclass TestNetworkMonitor : NetworkMonitor {\n\n    private val connectivityFlow = MutableStateFlow(true)\n\n    override val isOnline: Flow<Boolean> = connectivityFlow\n\n    /**\n     * A test-only API to set the connectivity state from tests.\n     */\n    fun setConnected(isConnected: Boolean) {\n        connectivityFlow.value = isConnected\n    }\n}\n"
  },
  {
    "path": "core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/TestSyncManager.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.testing.util\n\nimport com.google.samples.apps.nowinandroid.core.data.util.SyncManager\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.MutableStateFlow\n\nclass TestSyncManager : SyncManager {\n\n    private val syncStatusFlow = MutableStateFlow(false)\n\n    override val isSyncing: Flow<Boolean> = syncStatusFlow\n\n    override fun requestSync(): Unit = TODO(\"Not yet implemented\")\n\n    /**\n     * A test-only API to set the sync status from tests.\n     */\n    fun setSyncing(isSyncing: Boolean) {\n        syncStatusFlow.value = isSyncing\n    }\n}\n"
  },
  {
    "path": "core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/TestTimeZoneMonitor.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.testing.util\n\nimport com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.datetime.TimeZone\n\nclass TestTimeZoneMonitor : TimeZoneMonitor {\n\n    private val timeZoneFlow = MutableStateFlow(defaultTimeZone)\n\n    override val currentTimeZone: Flow<TimeZone> = timeZoneFlow\n\n    /**\n     * A test-only API to set the from tests.\n     */\n    fun setTimeZone(zoneId: TimeZone) {\n        timeZoneFlow.value = zoneId\n    }\n\n    companion object {\n        val defaultTimeZone: TimeZone = TimeZone.of(\"Europe/Warsaw\")\n    }\n}\n"
  },
  {
    "path": "core/ui/.gitignore",
    "content": "/build"
  },
  {
    "path": "core/ui/README.md",
    "content": "# `:core:ui`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :core\n    direction TB\n    :core:analytics[analytics]:::android-library\n    :core:designsystem[designsystem]:::android-library\n    :core:model[model]:::jvm-library\n    :core:ui[ui]:::android-library\n  end\n\n  :core:ui --> :core:analytics\n  :core:ui --> :core:designsystem\n  :core:ui --> :core:model\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "core/ui/build.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nplugins {\n    alias(libs.plugins.nowinandroid.android.library)\n    alias(libs.plugins.nowinandroid.android.library.compose)\n    alias(libs.plugins.nowinandroid.android.library.jacoco)\n}\n\nandroid {\n    namespace = \"com.google.samples.apps.nowinandroid.core.ui\"\n}\n\ndependencies {\n    api(libs.androidx.metrics)\n    api(projects.core.analytics)\n    api(projects.core.designsystem)\n    api(projects.core.model)\n\n    implementation(libs.androidx.browser)\n    implementation(libs.coil.kt)\n    implementation(libs.coil.kt.compose)\n\n    androidTestImplementation(libs.bundles.androidx.compose.ui.test)\n    androidTestImplementation(projects.core.testing)\n}\n"
  },
  {
    "path": "core/ui/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardTest.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.ui\n\nimport androidx.activity.ComponentActivity\nimport androidx.compose.ui.test.assertContentDescriptionEquals\nimport androidx.compose.ui.test.assertIsDisplayed\nimport androidx.compose.ui.test.junit4.createAndroidComposeRule\nimport androidx.compose.ui.test.onNodeWithContentDescription\nimport androidx.compose.ui.test.onNodeWithText\nimport com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData\nimport com.google.samples.apps.nowinandroid.core.testing.data.userNewsResourcesTestData\nimport org.junit.Rule\nimport org.junit.Test\n\nclass NewsResourceCardTest {\n    @get:Rule\n    val composeTestRule = createAndroidComposeRule<ComponentActivity>()\n\n    @Test\n    fun testMetaDataDisplay_withCodelabResource() {\n        val newsWithKnownResourceType = userNewsResourcesTestData[0]\n        lateinit var dateFormatted: String\n\n        composeTestRule.setContent {\n            NewsResourceCardExpanded(\n                userNewsResource = newsWithKnownResourceType,\n                isBookmarked = false,\n                hasBeenViewed = false,\n                onToggleBookmark = {},\n                onClick = {},\n                onTopicClick = {},\n            )\n\n            dateFormatted = dateFormatted(publishDate = newsWithKnownResourceType.publishDate)\n        }\n\n        composeTestRule\n            .onNodeWithText(\n                composeTestRule.activity.getString(\n                    R.string.core_ui_card_meta_data_text,\n                    dateFormatted,\n                    newsWithKnownResourceType.type,\n                ),\n            )\n            .assertExists()\n    }\n\n    @Test\n    fun testMetaDataDisplay_withEmptyResourceType() {\n        val newsWithEmptyResourceType = userNewsResourcesTestData[3]\n        lateinit var dateFormatted: String\n\n        composeTestRule.setContent {\n            NewsResourceCardExpanded(\n                userNewsResource = newsWithEmptyResourceType,\n                isBookmarked = false,\n                hasBeenViewed = false,\n                onToggleBookmark = {},\n                onClick = {},\n                onTopicClick = {},\n            )\n\n            dateFormatted = dateFormatted(publishDate = newsWithEmptyResourceType.publishDate)\n        }\n\n        composeTestRule\n            .onNodeWithText(dateFormatted)\n            .assertIsDisplayed()\n    }\n\n    @Test\n    fun testTopicsChipColorBackground_matchesFollowedState() {\n        composeTestRule.setContent {\n            NewsResourceTopics(\n                topics = followableTopicTestData,\n                onTopicClick = {},\n            )\n        }\n\n        for (followableTopic in followableTopicTestData) {\n            val topicName = followableTopic.topic.name\n            val expectedContentDescription = if (followableTopic.isFollowed) {\n                \"$topicName is followed\"\n            } else {\n                \"$topicName is not followed\"\n            }\n            composeTestRule\n                .onNodeWithText(topicName.uppercase())\n                .assertContentDescriptionEquals(expectedContentDescription)\n        }\n    }\n\n    @Test\n    fun testUnreadDot_displayedWhenUnread() {\n        val unreadNews = userNewsResourcesTestData[2]\n\n        composeTestRule.setContent {\n            NewsResourceCardExpanded(\n                userNewsResource = unreadNews,\n                isBookmarked = false,\n                hasBeenViewed = false,\n                onToggleBookmark = {},\n                onClick = {},\n                onTopicClick = {},\n            )\n        }\n\n        composeTestRule\n            .onNodeWithContentDescription(\n                composeTestRule.activity.getString(\n                    R.string.core_ui_unread_resource_dot_content_description,\n                ),\n            )\n            .assertIsDisplayed()\n    }\n\n    @Test\n    fun testUnreadDot_notDisplayedWhenRead() {\n        val readNews = userNewsResourcesTestData[0]\n\n        composeTestRule.setContent {\n            NewsResourceCardExpanded(\n                userNewsResource = readNews,\n                isBookmarked = false,\n                hasBeenViewed = true,\n                onToggleBookmark = {},\n                onClick = {},\n                onTopicClick = {},\n            )\n        }\n\n        composeTestRule\n            .onNodeWithContentDescription(\n                composeTestRule.activity.getString(\n                    R.string.core_ui_unread_resource_dot_content_description,\n                ),\n            )\n            .assertDoesNotExist()\n    }\n}\n"
  },
  {
    "path": "core/ui/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<manifest />\n"
  },
  {
    "path": "core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/AnalyticsExtensions.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.ui\n\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.DisposableEffect\nimport com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent\nimport com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent.Param\nimport com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent.ParamKeys\nimport com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent.Types\nimport com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper\nimport com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper\n\n/**\n * Classes and functions associated with analytics events for the UI.\n */\nfun AnalyticsHelper.logScreenView(screenName: String) {\n    logEvent(\n        AnalyticsEvent(\n            type = Types.SCREEN_VIEW,\n            extras = listOf(\n                Param(ParamKeys.SCREEN_NAME, screenName),\n            ),\n        ),\n    )\n}\n\nfun AnalyticsHelper.logNewsResourceOpened(newsResourceId: String) {\n    logEvent(\n        event = AnalyticsEvent(\n            type = \"news_resource_opened\",\n            extras = listOf(\n                Param(\"opened_news_resource\", newsResourceId),\n            ),\n        ),\n    )\n}\n\n/**\n * A side-effect which records a screen view event.\n */\n@Composable\nfun TrackScreenViewEvent(\n    screenName: String,\n    analyticsHelper: AnalyticsHelper = LocalAnalyticsHelper.current,\n) = DisposableEffect(Unit) {\n    analyticsHelper.logScreenView(screenName)\n    onDispose {}\n}\n"
  },
  {
    "path": "core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/DevicePreviews.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.ui\n\nimport androidx.compose.ui.tooling.preview.Preview\n\n/**\n * Multipreview annotation that represents various device sizes. Add this annotation to a composable\n * to render various devices.\n */\n@Preview(name = \"phone\", device = \"spec:shape=Normal,width=360,height=640,unit=dp,dpi=480\")\n@Preview(name = \"landscape\", device = \"spec:shape=Normal,width=640,height=360,unit=dp,dpi=480\")\n@Preview(name = \"foldable\", device = \"spec:shape=Normal,width=673,height=841,unit=dp,dpi=480\")\n@Preview(name = \"tablet\", device = \"spec:shape=Normal,width=1280,height=800,unit=dp,dpi=480\")\nannotation class DevicePreviews\n"
  },
  {
    "path": "core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/FollowableTopicPreviewParameterProvider.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n@file:Suppress(\"ktlint:standard:max-line-length\")\n\npackage com.google.samples.apps.nowinandroid.core.ui\n\nimport androidx.compose.ui.tooling.preview.PreviewParameterProvider\nimport com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic\nimport com.google.samples.apps.nowinandroid.core.model.data.Topic\n\n/**\n * This [PreviewParameterProvider](https://developer.android.com/reference/kotlin/androidx/compose/ui/tooling/preview/PreviewParameterProvider)\n * provides list of [FollowableTopic] for Composable previews.\n */\nclass FollowableTopicPreviewParameterProvider : PreviewParameterProvider<List<FollowableTopic>> {\n    override val values: Sequence<List<FollowableTopic>>\n        get() = sequenceOf(\n            listOf(\n                FollowableTopic(\n                    topic = Topic(\n                        id = \"2\",\n                        name = \"Headlines\",\n                        shortDescription = \"News we want everyone to see\",\n                        longDescription = \"Stay up to date with the latest events and announcements from Android!\",\n                        imageUrl = \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Headlines.svg?alt=media&token=506faab0-617a-4668-9e63-4a2fb996603f\",\n                        url = \"\",\n                    ),\n                    isFollowed = false,\n                ),\n                FollowableTopic(\n                    topic = Topic(\n                        id = \"3\",\n                        name = \"UI\",\n                        shortDescription = \"Material Design, Navigation, Text, Paging, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets\",\n                        longDescription = \"Learn how to optimize your app's user interface - everything that users can see and interact with. Stay up to date on topics such as Material Design, Navigation, Text, Paging, Compose, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets, and many more!\",\n                        imageUrl = \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_UI.svg?alt=media&token=0ee1842b-12e8-435f-87ba-a5bb02c47594\",\n                        url = \"\",\n                    ),\n                    isFollowed = true,\n                ),\n                FollowableTopic(\n                    topic = Topic(\n                        id = \"4\",\n                        name = \"Testing\",\n                        shortDescription = \"CI, Espresso, TestLab, etc\",\n                        longDescription = \"Testing is an integral part of the app development process. By running tests against your app consistently, you can verify your app's correctness, functional behavior, and usability before you release it publicly. Stay up to date on the latest tricks in CI, Espresso, and Firebase TestLab.\",\n                        imageUrl = \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Testing.svg?alt=media&token=a11533c4-7cc8-4b11-91a3-806158ebf428\",\n                        url = \"\",\n                    ),\n                    isFollowed = false,\n                ),\n            ),\n        )\n}\n"
  },
  {
    "path": "core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/InterestsItem.kt",
    "content": "/*\n * Copyright 2024 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.ui\n\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.ListItem\nimport androidx.compose.material3.ListItemDefaults\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Surface\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.semantics.selected\nimport androidx.compose.ui.semantics.semantics\nimport androidx.compose.ui.tooling.preview.Preview\nimport androidx.compose.ui.unit.dp\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.DynamicAsyncImage\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton\nimport com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\nimport com.google.samples.apps.nowinandroid.core.ui.R.string\n\n@Composable\nfun InterestsItem(\n    name: String,\n    following: Boolean,\n    topicImageUrl: String,\n    onClick: () -> Unit,\n    onFollowButtonClick: (Boolean) -> Unit,\n    modifier: Modifier = Modifier,\n    iconModifier: Modifier = Modifier,\n    description: String = \"\",\n    isSelected: Boolean = false,\n) {\n    ListItem(\n        leadingContent = {\n            InterestsIcon(topicImageUrl, iconModifier.size(48.dp))\n        },\n        headlineContent = {\n            Text(text = name)\n        },\n        supportingContent = {\n            Text(text = description)\n        },\n        trailingContent = {\n            NiaIconToggleButton(\n                checked = following,\n                onCheckedChange = onFollowButtonClick,\n                icon = {\n                    Icon(\n                        imageVector = NiaIcons.Add,\n                        contentDescription = stringResource(\n                            id = string.core_ui_interests_card_follow_button_content_desc,\n                        ),\n                    )\n                },\n                checkedIcon = {\n                    Icon(\n                        imageVector = NiaIcons.Check,\n                        contentDescription = stringResource(\n                            id = string.core_ui_interests_card_unfollow_button_content_desc,\n                        ),\n                    )\n                },\n            )\n        },\n        colors = ListItemDefaults.colors(\n            containerColor = if (isSelected) {\n                MaterialTheme.colorScheme.surfaceVariant\n            } else {\n                Color.Transparent\n            },\n        ),\n        modifier = modifier\n            .semantics(mergeDescendants = true) {\n                selected = isSelected\n            }\n            .clickable(enabled = true, onClick = onClick),\n    )\n}\n\n@Composable\nprivate fun InterestsIcon(topicImageUrl: String, modifier: Modifier = Modifier) {\n    if (topicImageUrl.isEmpty()) {\n        Icon(\n            modifier = modifier\n                .background(MaterialTheme.colorScheme.surface)\n                .padding(4.dp),\n            imageVector = NiaIcons.Person,\n            // decorative image\n            contentDescription = null,\n        )\n    } else {\n        DynamicAsyncImage(\n            imageUrl = topicImageUrl,\n            contentDescription = null,\n            modifier = modifier,\n        )\n    }\n}\n\n@Preview\n@Composable\nprivate fun InterestsCardPreview() {\n    NiaTheme {\n        Surface {\n            InterestsItem(\n                name = \"Compose\",\n                description = \"Description\",\n                following = false,\n                topicImageUrl = \"\",\n                onClick = { },\n                onFollowButtonClick = { },\n            )\n        }\n    }\n}\n\n@Preview\n@Composable\nprivate fun InterestsCardLongNamePreview() {\n    NiaTheme {\n        Surface {\n            InterestsItem(\n                name = \"This is a very very very very long name\",\n                description = \"Description\",\n                following = true,\n                topicImageUrl = \"\",\n                onClick = { },\n                onFollowButtonClick = { },\n            )\n        }\n    }\n}\n\n@Preview\n@Composable\nprivate fun InterestsCardLongDescriptionPreview() {\n    NiaTheme {\n        Surface {\n            InterestsItem(\n                name = \"Compose\",\n                description = \"This is a very very very very very very very \" +\n                    \"very very very long description\",\n                following = false,\n                topicImageUrl = \"\",\n                onClick = { },\n                onFollowButtonClick = { },\n            )\n        }\n    }\n}\n\n@Preview\n@Composable\nprivate fun InterestsCardWithEmptyDescriptionPreview() {\n    NiaTheme {\n        Surface {\n            InterestsItem(\n                name = \"Compose\",\n                description = \"\",\n                following = true,\n                topicImageUrl = \"\",\n                onClick = { },\n                onFollowButtonClick = { },\n            )\n        }\n    }\n}\n\n@Preview\n@Composable\nprivate fun InterestsCardSelectedPreview() {\n    NiaTheme {\n        Surface {\n            InterestsItem(\n                name = \"Compose\",\n                description = \"\",\n                following = true,\n                topicImageUrl = \"\",\n                onClick = { },\n                onFollowButtonClick = { },\n                isSelected = true,\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/JankStatsExtensions.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.ui\n\nimport androidx.compose.foundation.gestures.ScrollableState\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.DisposableEffect\nimport androidx.compose.runtime.DisposableEffectResult\nimport androidx.compose.runtime.DisposableEffectScope\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.snapshotFlow\nimport androidx.compose.ui.platform.LocalView\nimport androidx.metrics.performance.PerformanceMetricsState\nimport androidx.metrics.performance.PerformanceMetricsState.Holder\nimport kotlinx.coroutines.CoroutineScope\n\n/**\n * Retrieves [PerformanceMetricsState.Holder] from current [LocalView] and\n * remembers it until the View changes.\n * @see PerformanceMetricsState.getHolderForHierarchy\n */\n@Composable\nfun rememberMetricsStateHolder(): Holder {\n    val localView = LocalView.current\n\n    return remember(localView) {\n        PerformanceMetricsState.getHolderForHierarchy(localView)\n    }\n}\n\n/**\n * Convenience function to work with [PerformanceMetricsState] state. The side effect is\n * re-launched if any of the [keys] value is not equal to the previous composition.\n * @see TrackDisposableJank if you need to work with DisposableEffect to cleanup added state.\n */\n@Composable\nfun TrackJank(\n    vararg keys: Any,\n    reportMetric: suspend CoroutineScope.(state: Holder) -> Unit,\n) {\n    val metrics = rememberMetricsStateHolder()\n    LaunchedEffect(metrics, *keys) {\n        reportMetric(metrics)\n    }\n}\n\n/**\n * Convenience function to work with [PerformanceMetricsState] state that needs to be cleaned up.\n * The side effect is re-launched if any of the [keys] value is not equal to the previous composition.\n */\n@Composable\nfun TrackDisposableJank(\n    vararg keys: Any,\n    reportMetric: DisposableEffectScope.(state: Holder) -> DisposableEffectResult,\n) {\n    val metrics = rememberMetricsStateHolder()\n    DisposableEffect(metrics, *keys) {\n        reportMetric(this, metrics)\n    }\n}\n\n/**\n * Track jank while scrolling anything that's scrollable.\n */\n@Composable\nfun TrackScrollJank(scrollableState: ScrollableState, stateName: String) {\n    TrackJank(scrollableState) { metricsHolder ->\n        snapshotFlow { scrollableState.isScrollInProgress }.collect { isScrollInProgress ->\n            metricsHolder.state?.apply {\n                if (isScrollInProgress) {\n                    putState(stateName, \"Scrolling=true\")\n                } else {\n                    removeState(stateName)\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/LocalTimeZone.kt",
    "content": "/*\n * Copyright 2024 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.ui\n\nimport androidx.compose.runtime.compositionLocalOf\nimport kotlinx.datetime.TimeZone\n\n/**\n * TimeZone that can be provided with the TimeZoneMonitor.\n * This way, it's not needed to pass every single composable the time zone to show in UI.\n */\nval LocalTimeZone = compositionLocalOf { TimeZone.currentSystemDefault() }\n"
  },
  {
    "path": "core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.ui\n\nimport android.content.Context\nimport android.net.Uri\nimport androidx.annotation.ColorInt\nimport androidx.browser.customtabs.CustomTabColorSchemeParams\nimport androidx.browser.customtabs.CustomTabsIntent\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.lazy.LazyListScope\nimport androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope\nimport androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid\nimport androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells\nimport androidx.compose.foundation.lazy.staggeredgrid.items\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.toArgb\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.tooling.preview.Devices\nimport androidx.compose.ui.tooling.preview.Preview\nimport androidx.compose.ui.tooling.preview.PreviewParameter\nimport androidx.compose.ui.unit.dp\nimport com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\nimport com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource\n\n/**\n * An extension on [LazyListScope] defining a feed with news resources.\n * Depending on the [feedState], this might emit no items.\n */\nfun LazyStaggeredGridScope.newsFeed(\n    feedState: NewsFeedUiState,\n    onNewsResourcesCheckedChanged: (String, Boolean) -> Unit,\n    onNewsResourceViewed: (String) -> Unit,\n    onTopicClick: (String) -> Unit,\n    onExpandedCardClick: () -> Unit = {},\n) {\n    when (feedState) {\n        NewsFeedUiState.Loading -> Unit\n        is NewsFeedUiState.Success -> {\n            items(\n                items = feedState.feed,\n                key = { it.id },\n                contentType = { \"newsFeedItem\" },\n            ) { userNewsResource ->\n                val context = LocalContext.current\n                val analyticsHelper = LocalAnalyticsHelper.current\n                val backgroundColor = MaterialTheme.colorScheme.background.toArgb()\n\n                NewsResourceCardExpanded(\n                    userNewsResource = userNewsResource,\n                    isBookmarked = userNewsResource.isSaved,\n                    onClick = {\n                        onExpandedCardClick()\n                        analyticsHelper.logNewsResourceOpened(\n                            newsResourceId = userNewsResource.id,\n                        )\n                        launchCustomChromeTab(context, Uri.parse(userNewsResource.url), backgroundColor)\n\n                        onNewsResourceViewed(userNewsResource.id)\n                    },\n                    hasBeenViewed = userNewsResource.hasBeenViewed,\n                    onToggleBookmark = {\n                        onNewsResourcesCheckedChanged(\n                            userNewsResource.id,\n                            !userNewsResource.isSaved,\n                        )\n                    },\n                    onTopicClick = onTopicClick,\n                    modifier = Modifier\n                        .padding(horizontal = 8.dp)\n                        .animateItem(),\n                )\n            }\n        }\n    }\n}\n\nfun launchCustomChromeTab(context: Context, uri: Uri, @ColorInt toolbarColor: Int) {\n    val customTabBarColor = CustomTabColorSchemeParams.Builder()\n        .setToolbarColor(toolbarColor).build()\n    val customTabsIntent = CustomTabsIntent.Builder()\n        .setDefaultColorSchemeParams(customTabBarColor)\n        .build()\n\n    customTabsIntent.launchUrl(context, uri)\n}\n\n/**\n * A sealed hierarchy describing the state of the feed of news resources.\n */\nsealed interface NewsFeedUiState {\n    /**\n     * The feed is still loading.\n     */\n    data object Loading : NewsFeedUiState\n\n    /**\n     * The feed is loaded with the given list of news resources.\n     */\n    data class Success(\n        /**\n         * The list of news resources contained in this feed.\n         */\n        val feed: List<UserNewsResource>,\n    ) : NewsFeedUiState\n}\n\n@Preview\n@Composable\nprivate fun NewsFeedLoadingPreview() {\n    NiaTheme {\n        LazyVerticalStaggeredGrid(columns = StaggeredGridCells.Adaptive(300.dp)) {\n            newsFeed(\n                feedState = NewsFeedUiState.Loading,\n                onNewsResourcesCheckedChanged = { _, _ -> },\n                onNewsResourceViewed = {},\n                onTopicClick = {},\n            )\n        }\n    }\n}\n\n@Preview\n@Preview(device = Devices.TABLET)\n@Composable\nprivate fun NewsFeedContentPreview(\n    @PreviewParameter(UserNewsResourcePreviewParameterProvider::class)\n    userNewsResources: List<UserNewsResource>,\n) {\n    NiaTheme {\n        LazyVerticalStaggeredGrid(columns = StaggeredGridCells.Adaptive(300.dp)) {\n            newsFeed(\n                feedState = NewsFeedUiState.Success(userNewsResources),\n                onNewsResourcesCheckedChanged = { _, _ -> },\n                onNewsResourceViewed = {},\n                onTopicClick = {},\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.ui\n\nimport android.content.ClipData\nimport android.os.Build.VERSION\nimport android.os.Build.VERSION_CODES\nimport android.view.View\nimport androidx.compose.foundation.Canvas\nimport androidx.compose.foundation.ExperimentalFoundationApi\nimport androidx.compose.foundation.Image\nimport androidx.compose.foundation.draganddrop.dragAndDropSource\nimport androidx.compose.foundation.horizontalScroll\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material3.Card\nimport androidx.compose.material3.CardDefaults\nimport androidx.compose.material3.CircularProgressIndicator\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Surface\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.CompositionLocalProvider\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draganddrop.DragAndDropTransferData\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.layout.ContentScale\nimport androidx.compose.ui.platform.LocalInspectionMode\nimport androidx.compose.ui.platform.testTag\nimport androidx.compose.ui.res.painterResource\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.semantics.contentDescription\nimport androidx.compose.ui.semantics.onClick\nimport androidx.compose.ui.semantics.semantics\nimport androidx.compose.ui.tooling.preview.Preview\nimport androidx.compose.ui.tooling.preview.PreviewParameter\nimport androidx.compose.ui.unit.dp\nimport coil.compose.AsyncImagePainter\nimport coil.compose.rememberAsyncImagePainter\nimport com.google.samples.apps.nowinandroid.core.designsystem.R.drawable\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopicTag\nimport com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\nimport com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic\nimport com.google.samples.apps.nowinandroid.core.model.data.NewsResource\nimport com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource\nimport kotlinx.datetime.Instant\nimport kotlinx.datetime.toJavaInstant\nimport kotlinx.datetime.toJavaZoneId\nimport java.time.format.DateTimeFormatter\nimport java.time.format.FormatStyle\nimport java.util.Locale\n\n/**\n * [NewsResource] card used on the following screens: For You, Saved\n */\n\n@OptIn(ExperimentalFoundationApi::class)\n@Composable\nfun NewsResourceCardExpanded(\n    userNewsResource: UserNewsResource,\n    isBookmarked: Boolean,\n    hasBeenViewed: Boolean,\n    onToggleBookmark: () -> Unit,\n    onClick: () -> Unit,\n    onTopicClick: (String) -> Unit,\n    modifier: Modifier = Modifier,\n) {\n    val clickActionLabel = stringResource(R.string.core_ui_card_tap_action)\n    val sharingLabel = stringResource(R.string.core_ui_feed_sharing)\n    val sharingContent = stringResource(\n        R.string.core_ui_feed_sharing_data,\n        userNewsResource.title,\n        userNewsResource.url,\n    )\n\n    val dragAndDropFlags = if (VERSION.SDK_INT >= VERSION_CODES.N) {\n        View.DRAG_FLAG_GLOBAL\n    } else {\n        0\n    }\n\n    Card(\n        onClick = onClick,\n        shape = RoundedCornerShape(16.dp),\n        colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),\n        // Use custom label for accessibility services to communicate button's action to user.\n        // Pass null for action to only override the label and not the actual action.\n        modifier = modifier\n            .semantics {\n                onClick(label = clickActionLabel, action = null)\n            }\n            .testTag(\"newsResourceCard:${userNewsResource.id}\"),\n    ) {\n        Column {\n            if (!userNewsResource.headerImageUrl.isNullOrEmpty()) {\n                Row {\n                    NewsResourceHeaderImage(userNewsResource.headerImageUrl)\n                }\n            }\n            Box(\n                modifier = Modifier.padding(16.dp),\n            ) {\n                Column {\n                    Spacer(modifier = Modifier.height(12.dp))\n                    Row {\n                        NewsResourceTitle(\n                            userNewsResource.title,\n                            modifier = Modifier\n                                .fillMaxWidth((.8f))\n                                .dragAndDropSource { _ ->\n                                    DragAndDropTransferData(\n                                        ClipData.newPlainText(\n                                            sharingLabel,\n                                            sharingContent,\n                                        ),\n                                        flags = dragAndDropFlags,\n                                    )\n                                },\n                        )\n                        Spacer(modifier = Modifier.weight(1f))\n                        BookmarkButton(isBookmarked, onToggleBookmark)\n                    }\n                    Spacer(modifier = Modifier.height(14.dp))\n                    Row(verticalAlignment = Alignment.CenterVertically) {\n                        if (!hasBeenViewed) {\n                            NotificationDot(\n                                color = MaterialTheme.colorScheme.tertiary,\n                                modifier = Modifier.size(8.dp),\n                            )\n                            Spacer(modifier = Modifier.size(6.dp))\n                        }\n                        NewsResourceMetaData(userNewsResource.publishDate, userNewsResource.type)\n                    }\n                    Spacer(modifier = Modifier.height(14.dp))\n                    NewsResourceShortDescription(userNewsResource.content)\n                    Spacer(modifier = Modifier.height(12.dp))\n                    NewsResourceTopics(\n                        topics = userNewsResource.followableTopics,\n                        onTopicClick = onTopicClick,\n                    )\n                }\n            }\n        }\n    }\n}\n\n@Composable\nfun NewsResourceHeaderImage(\n    headerImageUrl: String?,\n) {\n    var isLoading by remember { mutableStateOf(true) }\n    var isError by remember { mutableStateOf(false) }\n    val imageLoader = rememberAsyncImagePainter(\n        model = headerImageUrl,\n        onState = { state ->\n            isLoading = state is AsyncImagePainter.State.Loading\n            isError = state is AsyncImagePainter.State.Error\n        },\n    )\n    val isLocalInspection = LocalInspectionMode.current\n    Box(\n        modifier = Modifier\n            .fillMaxWidth()\n            .height(180.dp),\n        contentAlignment = Alignment.Center,\n    ) {\n        if (isLoading) {\n            // Display a progress bar while loading\n            CircularProgressIndicator(\n                modifier = Modifier\n                    .align(Alignment.Center)\n                    .size(80.dp),\n                color = MaterialTheme.colorScheme.tertiary,\n            )\n        }\n\n        Image(\n            modifier = Modifier\n                .fillMaxWidth()\n                .height(180.dp),\n            contentScale = ContentScale.Crop,\n            painter = if (isError.not() && !isLocalInspection) {\n                imageLoader\n            } else {\n                painterResource(drawable.core_designsystem_ic_placeholder_default)\n            },\n            // TODO b/226661685: Investigate using alt text of  image to populate content description\n            // decorative image,\n            contentDescription = null,\n        )\n    }\n}\n\n@Composable\nfun NewsResourceTitle(\n    newsResourceTitle: String,\n    modifier: Modifier = Modifier,\n) {\n    Text(newsResourceTitle, style = MaterialTheme.typography.headlineSmall, modifier = modifier)\n}\n\n@Composable\nfun BookmarkButton(\n    isBookmarked: Boolean,\n    onClick: () -> Unit,\n    modifier: Modifier = Modifier,\n) {\n    NiaIconToggleButton(\n        checked = isBookmarked,\n        onCheckedChange = { onClick() },\n        modifier = modifier,\n        icon = {\n            Icon(\n                imageVector = NiaIcons.BookmarkBorder,\n                contentDescription = stringResource(R.string.core_ui_bookmark),\n            )\n        },\n        checkedIcon = {\n            Icon(\n                imageVector = NiaIcons.Bookmark,\n                contentDescription = stringResource(R.string.core_ui_unbookmark),\n            )\n        },\n    )\n}\n\n@Composable\nfun NotificationDot(\n    color: Color,\n    modifier: Modifier = Modifier,\n) {\n    val description = stringResource(R.string.core_ui_unread_resource_dot_content_description)\n    Canvas(\n        modifier = modifier\n            .semantics { contentDescription = description },\n        onDraw = {\n            drawCircle(\n                color,\n                radius = size.minDimension / 2,\n            )\n        },\n    )\n}\n\n@Composable\nfun dateFormatted(publishDate: Instant): String = DateTimeFormatter\n    .ofLocalizedDate(FormatStyle.MEDIUM)\n    .withLocale(Locale.getDefault())\n    .withZone(LocalTimeZone.current.toJavaZoneId())\n    .format(publishDate.toJavaInstant())\n\n@Composable\nfun NewsResourceMetaData(\n    publishDate: Instant,\n    resourceType: String,\n) {\n    val formattedDate = dateFormatted(publishDate)\n    Text(\n        if (resourceType.isNotBlank()) {\n            stringResource(R.string.core_ui_card_meta_data_text, formattedDate, resourceType)\n        } else {\n            formattedDate\n        },\n        style = MaterialTheme.typography.labelSmall,\n    )\n}\n\n@Composable\nfun NewsResourceShortDescription(\n    newsResourceShortDescription: String,\n) {\n    Text(newsResourceShortDescription, style = MaterialTheme.typography.bodyLarge)\n}\n\n@Composable\nfun NewsResourceTopics(\n    topics: List<FollowableTopic>,\n    onTopicClick: (String) -> Unit,\n    modifier: Modifier = Modifier,\n) {\n    Row(\n        // causes narrow chips\n        modifier = modifier.horizontalScroll(rememberScrollState()),\n        horizontalArrangement = Arrangement.spacedBy(4.dp),\n    ) {\n        for (followableTopic in topics) {\n            NiaTopicTag(\n                followed = followableTopic.isFollowed,\n                onClick = { onTopicClick(followableTopic.topic.id) },\n                text = {\n                    val contentDescription = if (followableTopic.isFollowed) {\n                        stringResource(\n                            R.string.core_ui_topic_chip_content_description_when_followed,\n                            followableTopic.topic.name,\n                        )\n                    } else {\n                        stringResource(\n                            R.string.core_ui_topic_chip_content_description_when_not_followed,\n                            followableTopic.topic.name,\n                        )\n                    }\n                    Text(\n                        text = followableTopic.topic.name.uppercase(Locale.getDefault()),\n                        modifier = Modifier\n                            .semantics {\n                                this.contentDescription = contentDescription\n                            }\n                            .testTag(\"topicTag:${followableTopic.topic.id}\"),\n                    )\n                },\n            )\n        }\n    }\n}\n\n@Preview(\"Bookmark Button\")\n@Composable\nprivate fun BookmarkButtonPreview() {\n    NiaTheme {\n        Surface {\n            BookmarkButton(isBookmarked = false, onClick = { })\n        }\n    }\n}\n\n@Preview(\"Bookmark Button Bookmarked\")\n@Composable\nprivate fun BookmarkButtonBookmarkedPreview() {\n    NiaTheme {\n        Surface {\n            BookmarkButton(isBookmarked = true, onClick = { })\n        }\n    }\n}\n\n@Preview(\"NewsResourceCardExpanded\")\n@Composable\nprivate fun ExpandedNewsResourcePreview(\n    @PreviewParameter(UserNewsResourcePreviewParameterProvider::class)\n    userNewsResources: List<UserNewsResource>,\n) {\n    CompositionLocalProvider(\n        LocalInspectionMode provides true,\n    ) {\n        NiaTheme {\n            Surface {\n                NewsResourceCardExpanded(\n                    userNewsResource = userNewsResources[0],\n                    isBookmarked = true,\n                    hasBeenViewed = false,\n                    onToggleBookmark = {},\n                    onClick = {},\n                    onTopicClick = {},\n                )\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.ui\n\nimport android.net.Uri\nimport androidx.compose.foundation.lazy.LazyListScope\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.toArgb\nimport androidx.compose.ui.platform.LocalContext\nimport com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper\nimport com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource\n\n/**\n * Extension function for displaying a [List] of [NewsResourceCardExpanded] backed by a list of\n * [UserNewsResource]s.\n *\n * [onToggleBookmark] defines the action invoked when a user wishes to bookmark an item\n * When a news resource card is tapped it will open the news resource URL in a Chrome Custom Tab.\n */\nfun LazyListScope.userNewsResourceCardItems(\n    items: List<UserNewsResource>,\n    onToggleBookmark: (item: UserNewsResource) -> Unit,\n    onNewsResourceViewed: (String) -> Unit,\n    onTopicClick: (String) -> Unit,\n    itemModifier: Modifier = Modifier,\n) = items(\n    items = items,\n    key = { it.id },\n    itemContent = { userNewsResource ->\n        val resourceUrl = Uri.parse(userNewsResource.url)\n        val backgroundColor = MaterialTheme.colorScheme.background.toArgb()\n        val context = LocalContext.current\n        val analyticsHelper = LocalAnalyticsHelper.current\n\n        NewsResourceCardExpanded(\n            userNewsResource = userNewsResource,\n            isBookmarked = userNewsResource.isSaved,\n            hasBeenViewed = userNewsResource.hasBeenViewed,\n            onToggleBookmark = { onToggleBookmark(userNewsResource) },\n            onClick = {\n                analyticsHelper.logNewsResourceOpened(\n                    newsResourceId = userNewsResource.id,\n                )\n                launchCustomChromeTab(context, resourceUrl, backgroundColor)\n                onNewsResourceViewed(userNewsResource.id)\n            },\n            onTopicClick = onTopicClick,\n            modifier = itemModifier,\n        )\n    },\n)\n"
  },
  {
    "path": "core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n@file:Suppress(\"ktlint:standard:max-line-length\")\n\npackage com.google.samples.apps.nowinandroid.core.ui\n\nimport androidx.compose.ui.tooling.preview.PreviewParameterProvider\nimport com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig\nimport com.google.samples.apps.nowinandroid.core.model.data.NewsResource\nimport com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand\nimport com.google.samples.apps.nowinandroid.core.model.data.Topic\nimport com.google.samples.apps.nowinandroid.core.model.data.UserData\nimport com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource\nimport com.google.samples.apps.nowinandroid.core.ui.PreviewParameterData.newsResources\nimport kotlinx.datetime.Instant\nimport kotlinx.datetime.LocalDateTime\nimport kotlinx.datetime.TimeZone\nimport kotlinx.datetime.toInstant\n\n/**\n * This [PreviewParameterProvider](https://developer.android.com/reference/kotlin/androidx/compose/ui/tooling/preview/PreviewParameterProvider)\n * provides list of [UserNewsResource] for Composable previews.\n */\nclass UserNewsResourcePreviewParameterProvider : PreviewParameterProvider<List<UserNewsResource>> {\n\n    override val values: Sequence<List<UserNewsResource>> = sequenceOf(newsResources)\n}\n\nobject PreviewParameterData {\n\n    private val userData: UserData = UserData(\n        bookmarkedNewsResources = setOf(\"1\", \"3\"),\n        viewedNewsResources = setOf(\"1\", \"2\", \"4\"),\n        followedTopics = emptySet(),\n        themeBrand = ThemeBrand.ANDROID,\n        darkThemeConfig = DarkThemeConfig.DARK,\n        shouldHideOnboarding = true,\n        useDynamicColor = false,\n    )\n\n    val topics = listOf(\n        Topic(\n            id = \"2\",\n            name = \"Headlines\",\n            shortDescription = \"News we want everyone to see\",\n            longDescription = \"Stay up to date with the latest events and announcements from Android!\",\n            imageUrl = \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Headlines.svg?alt=media&token=506faab0-617a-4668-9e63-4a2fb996603f\",\n            url = \"\",\n        ),\n        Topic(\n            id = \"3\",\n            name = \"UI\",\n            shortDescription = \"Material Design, Navigation, Text, Paging, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets\",\n            longDescription = \"Learn how to optimize your app's user interface - everything that users can see and interact with. Stay up to date on topics such as Material Design, Navigation, Text, Paging, Compose, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets, and many more!\",\n            imageUrl = \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_UI.svg?alt=media&token=0ee1842b-12e8-435f-87ba-a5bb02c47594\",\n            url = \"\",\n        ),\n        Topic(\n            id = \"4\",\n            name = \"Testing\",\n            shortDescription = \"CI, Espresso, TestLab, etc\",\n            longDescription = \"Testing is an integral part of the app development process. By running tests against your app consistently, you can verify your app's correctness, functional behavior, and usability before you release it publicly. Stay up to date on the latest tricks in CI, Espresso, and Firebase TestLab.\",\n            imageUrl = \"https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Testing.svg?alt=media&token=a11533c4-7cc8-4b11-91a3-806158ebf428\",\n            url = \"\",\n        ),\n    )\n\n    val newsResources = listOf(\n        UserNewsResource(\n            newsResource = NewsResource(\n                id = \"1\",\n                title = \"Android Basics with Compose\",\n                content = \"We released the first two units of Android Basics with Compose, our first free course that teaches Android Development with Jetpack Compose to anyone; you do not need any prior programming experience other than basic computer literacy to get started. You’ll learn the fundamentals of programming in Kotlin while building Android apps using Jetpack Compose, Android’s modern toolkit that simplifies and accelerates native UI development. These two units are just the beginning; more will be coming soon. Check out Android Basics with Compose to get started on your Android development journey\",\n                url = \"https://android-developers.googleblog.com/2022/05/new-android-basics-with-compose-course.html\",\n                headerImageUrl = \"https://developer.android.com/images/hero-assets/android-basics-compose.svg\",\n                publishDate = LocalDateTime(\n                    year = 2022,\n                    monthNumber = 5,\n                    dayOfMonth = 4,\n                    hour = 23,\n                    minute = 0,\n                    second = 0,\n                    nanosecond = 0,\n                ).toInstant(TimeZone.UTC),\n                type = \"Codelab\",\n                topics = listOf(topics[2]),\n            ),\n            userData = userData,\n        ),\n        UserNewsResource(\n            newsResource = NewsResource(\n                id = \"2\",\n                title = \"Thanks for helping us reach 1M YouTube Subscribers\",\n                content = \"Thank you everyone for following the Now in Android series and everything the \" +\n                    \"Android Developers YouTube channel has to offer. During the Android Developer \" +\n                    \"Summit, our YouTube channel reached 1 million subscribers! Here’s a small video to \" +\n                    \"thank you all.\",\n                url = \"https://youtu.be/-fJ6poHQrjM\",\n                headerImageUrl = \"https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg\",\n                publishDate = Instant.parse(\"2021-11-09T00:00:00.000Z\"),\n                type = \"Video 📺\",\n                topics = topics.take(2),\n            ),\n            userData = userData,\n        ),\n        UserNewsResource(\n            newsResource = NewsResource(\n                id = \"3\",\n                title = \"Transformations and customisations in the Paging Library\",\n                content = \"A demonstration of different operations that can be performed \" +\n                    \"with Paging. Transformations like inserting separators, when to \" +\n                    \"create a new pager, and customisation options for consuming \" +\n                    \"PagingData.\",\n                url = \"https://youtu.be/ZARz0pjm5YM\",\n                headerImageUrl = \"https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg\",\n                publishDate = Instant.parse(\"2021-11-01T00:00:00.000Z\"),\n                type = \"Video 📺\",\n                topics = listOf(topics[2]),\n            ),\n            userData = userData,\n        ),\n    )\n}\n"
  },
  {
    "path": "core/ui/src/main/res/values/strings.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2021 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<resources>\n    <string name=\"core_ui_bookmark\">Bookmark</string>\n    <string name=\"core_ui_unbookmark\">Unbookmark</string>\n    <string name=\"core_ui_back\">Back</string>\n\n    <string name=\"core_ui_unread_resource_dot_content_description\">Unread</string>\n\n    <string name=\"core_ui_card_tap_action\">Open Resource Link</string>\n    <string name=\"core_ui_card_meta_data_text\">%1$s • %2$s</string>\n\n    <string name=\"core_ui_topic_chip_content_description_when_followed\">%1$s is followed</string>\n    <string name=\"core_ui_topic_chip_content_description_when_not_followed\">%1$s is not followed</string>\n\n    <string name=\"core_ui_interests_card_follow_button_content_desc\">Follow interest</string>\n    <string name=\"core_ui_interests_card_unfollow_button_content_desc\">Unfollow interest</string>\n    <string name=\"core_ui_feed_sharing\">Feed sharing</string>\n    <string name=\"core_ui_feed_sharing_data\">%1$s: %2$s</string>\n</resources>\n"
  },
  {
    "path": "docs/ArchitectureLearningJourney.md",
    "content": "# Architecture Learning Journey\n\nIn this learning journey you will learn about the Now in Android app architecture: its layers, key classes and the interactions between them.\n\n\n## Goals and requirements\n\nThe goals for the app architecture are:\n\n\n\n*   Follow the [official architecture guidance](https://developer.android.com/jetpack/guide) as closely as possible.\n*   Easy for developers to understand, nothing too experimental.\n*   Support multiple developers working on the same codebase.\n*   Facilitate local and instrumented tests, both on the developer’s machine and using Continuous Integration (CI).\n*   Minimize build times.\n\n\n## Architecture overview\n\nThe app architecture has three layers: a [data layer](https://developer.android.com/jetpack/guide/data-layer), a [domain layer](https://developer.android.com/jetpack/guide/domain-layer) and a [UI layer](https://developer.android.com/jetpack/guide/ui-layer).\n\n\n<center>\n<img src=\"images/architecture-1-overall.png\" width=\"600px\" alt=\"Diagram showing overall app architecture\" />\n</center>\n\n> [!NOTE]  \n> The official Android architecture is different from other architectures, such as \"Clean Architecture\". Concepts from other architectures may not apply here, or be applied in different ways. [More discussion here](https://github.com/android/nowinandroid/discussions/1273).\n\nThe architecture follows a reactive programming model with [unidirectional data flow](https://developer.android.com/jetpack/guide/ui-layer#udf). With the data layer at the bottom, the key concepts are:\n\n\n\n*   Higher layers react to changes in lower layers.\n*   Events flow down.\n*   Data flows up.\n\nThe data flow is achieved using streams, implemented using [Kotlin Flows](https://developer.android.com/kotlin/flow).\n\n\n### Example: Displaying news on the For You screen\n\nWhen the app is first run it will attempt to load a list of news resources from a remote server (when the `prod` build flavor is selected, `demo` builds will use local data). Once loaded, these are shown to the user based on the interests they choose.\n\nThe following diagram shows the events which occur and how data flows from the relevant objects to achieve this.\n\n\n![Diagram showing how news resources are displayed on the For You screen](images/architecture-2-example.png \"Diagram showing how news resources are displayed on the For You screen\")\n\n\nHere's what's happening in each step. The easiest way to find the associated code is to load the project into Android Studio and search for the text in the Code column (handy shortcut: tap <kbd>⇧ SHIFT</kbd> twice).\n\n\n<table>\n  <tr>\n   <td><strong>Step</strong>\n   </td>\n   <td><strong>Description</strong>\n   </td>\n   <td><strong>Code </strong>\n   </td>\n  </tr>\n  <tr>\n   <td>1\n   </td>\n   <td>On app startup, a <a href=\"https://developer.android.com/topic/libraries/architecture/workmanager\">WorkManager</a> job to sync all repositories is enqueued.\n   </td>\n   <td><code>Sync.initialize</code>\n   </td>\n  </tr>\n  <tr>\n   <td>2\n   </td>\n   <td>The <code>ForYouViewModel</code> calls <code>GetUserNewsResourcesUseCase</code> to obtain a stream of news resources with their bookmarked/saved state. No items will be emitted into this stream until both the user and news repositories emit an item. While waiting, the feed state is set to <code>Loading</code>.\n   </td>\n   <td>Search for usages of <code>NewsFeedUiState.Loading</code>\n   </td>\n  </tr>\n  <tr>\n   <td>3\n   </td>\n   <td>The user data repository obtains a stream of <code>UserData</code> objects from a local data source backed by Proto DataStore.\n   </td>\n   <td><code>NiaPreferencesDataSource.userData</code>\n   </td>\n  </tr>\n  <tr>\n   <td>4\n   </td>\n   <td>WorkManager executes the sync job which calls <code>OfflineFirstNewsRepository</code> to start synchronizing data with the remote data source.\n   </td>\n   <td><code>SyncWorker.doWork</code>\n   </td>\n  </tr>\n  <tr>\n   <td>5\n   </td>\n   <td><code>OfflineFirstNewsRepository</code> calls <code>RetrofitNiaNetwork</code> to execute the actual API request using <a href=\"https://square.github.io/retrofit/\">Retrofit</a>.\n   </td>\n   <td><code>OfflineFirstNewsRepository.syncWith</code>\n   </td>\n  </tr>\n  <tr>\n   <td>6\n   </td>\n   <td><code>RetrofitNiaNetwork</code> calls the REST API on the remote server.\n   </td>\n   <td><code>RetrofitNiaNetwork.getNewsResources</code>\n   </td>\n  </tr>\n  <tr>\n   <td>7\n   </td>\n   <td><code>RetrofitNiaNetwork</code> receives the network response from the remote server.\n   </td>\n   <td><code>RetrofitNiaNetwork.getNewsResources</code>\n   </td>\n  </tr>\n  <tr>\n   <td>8\n   </td>\n   <td><code>OfflineFirstNewsRepository</code> syncs the remote data with <code>NewsResourceDao</code> by inserting, updating or deleting data in a local <a href=\"https://developer.android.com/training/data-storage/room\">Room database</a>.\n   </td>\n   <td><code>OfflineFirstNewsRepository.syncWith</code>\n   </td>\n  </tr>\n  <tr>\n   <td>9\n   </td>\n   <td>When data changes in <code>NewsResourceDao</code> it is emitted into the news resources data stream (which is a <a href=\"https://developer.android.com/kotlin/flow\">Flow</a>).\n   </td>\n   <td><code>NewsResourceDao.getNewsResources</code>\n   </td>\n  </tr>\n  <tr>\n   <td>10\n   </td>\n   <td><code>OfflineFirstNewsRepository</code> acts as an <a href=\"https://developer.android.com/kotlin/flow#modify\">intermediate operator</a> on this stream, transforming the incoming <code>PopulatedNewsResource</code> (a database model, internal to the data layer) to the public <code>NewsResource</code> model which is consumed by other layers.\n   </td>\n   <td><code>OfflineFirstNewsRepository.getNewsResources</code>\n   </td>\n  </tr>\n  <tr>\n   <td>11\n   </td>\n   <td><code>GetUserNewsResourcesUseCase</code> combines the list of news resources with the user data to emit a list of <code>UserNewsResource</code>s.  \n   </td>\n   <td><code>GetUserNewsResourcesUseCase.invoke</code>\n   </td>\n  </tr>\n  <tr>\n   <td>12\n   </td>\n   <td>When <code>ForYouViewModel</code> receives the saveable news resources it updates the feed state to <code>Success</code>.\n\n  <code>ForYouScreen</code> then uses the saveable news resources in the state to render the screen.\n   </td>\n   <td>Search for instances of <code>NewsFeedUiState.Success</code>\n   </td>\n  </tr>\n</table>\n\n\n\n## Data layer\n\nThe data layer is implemented as an offline-first source of app data and business logic. It is the source of truth for all data in the app.\n\n\n\n![Diagram showing the data layer architecture](images/architecture-3-data-layer.png \"Diagram showing the data layer architecture\")\n\n\nEach repository has its own models. For example, the `TopicsRepository` has a `Topic` model and the `NewsRepository` has a `NewsResource` model.\n\nRepositories are the public API for other layers, they provide the _only_ way to access the app data. The repositories typically offer one or more methods for reading and writing data.\n\n\n### Reading data\n\nData is exposed as data streams. This means each client of the repository must be prepared to react to data changes. Data is not exposed as a snapshot (e.g. `getModel`) because there's no guarantee that it will still be valid by the time it is used.\n\nReads are performed from local storage as the source of truth, therefore errors are not expected when reading from `Repository` instances. However, errors may occur when trying to reconcile data in local storage with remote sources. For more on error reconciliation, check the data synchronization section below.\n\n_Example: Read a list of topics_\n\nA list of Topics can be obtained by subscribing to `TopicsRepository::getTopics` flow which emits `List<Topic>`.\n\nWhenever the list of topics changes (for example, when a new topic is added), the updated `List<Topic>` is emitted into the stream.\n\n\n### Writing data\n\nTo write data, the repository provides suspend functions. It is up to the caller to ensure that their execution is suitably scoped.\n\n_Example: Follow a topic_\n\nSimply call `UserDataRepository.toggleFollowedTopicId` with the ID of the topic the user wishes to follow and `followed=true` to indicate that the topic should be followed (use `false` to unfollow a topic).\n\n\n### Data sources\n\nA repository may depend on one or more data sources. For example, the `OfflineFirstTopicsRepository` depends on the following data sources:\n\n\n<table>\n  <tr>\n   <td><strong>Name</strong>\n   </td>\n   <td><strong>Backed by</strong>\n   </td>\n   <td><strong>Purpose</strong>\n   </td>\n  </tr>\n  <tr>\n   <td>TopicsDao\n   </td>\n   <td><a href=\"https://developer.android.com/training/data-storage/room\">Room/SQLite</a>\n   </td>\n   <td>Persistent relational data associated with Topics\n   </td>\n  </tr>\n  <tr>\n   <td>NiaPreferencesDataSource\n   </td>\n   <td><a href=\"https://developer.android.com/topic/libraries/architecture/datastore\">Proto DataStore</a>\n   </td>\n   <td>Persistent unstructured data associated with user preferences, specifically which Topics the user is interested in. This is defined and modeled in a .proto file, using the protobuf syntax.\n   </td>\n  </tr>\n  <tr>\n   <td>NiaNetworkDataSource\n   </td>\n   <td>Remote API accessed using Retrofit\n   </td>\n   <td>Data for topics, provided through REST API endpoints as JSON.\n   </td>\n  </tr>\n</table>\n\n\n\n### Data synchronization\n\nRepositories are responsible for reconciling data in local storage with remote sources. Once data is obtained from a remote data source it is immediately written to local storage. The  updated data is emitted from local storage (Room) into the relevant data stream and received by any listening clients.\n\nThis approach ensures that the read and write concerns of the app are separate and do not interfere with each other.\n\nIn the case of errors during data synchronization, an exponential backoff strategy is employed. This is delegated to `WorkManager` via the `SyncWorker`, an implementation of the `Synchronizer` interface.\n\nSee the `OfflineFirstNewsRepository.syncWith` for an example of data synchronization.\n\n## Domain layer\nThe [domain layer](https://developer.android.com/topic/architecture/domain-layer) contains use cases. These are classes which have a single invocable method (`operator fun invoke`) containing business logic. \n\nThese use cases are used to simplify and remove duplicate logic from ViewModels. They typically combine and transform data from repositories. \n\nFor example, `GetUserNewsResourcesUseCase` combines a stream (implemented using `Flow`) of `NewsResource`s from a `NewsRepository` with a stream of `UserData` objects from a `UserDataRepository` to create a stream of `UserNewsResource`s. This stream is used by various ViewModels to display news resources on screen with their bookmarked state.  \n\nNotably, the domain layer in Now in Android _does not_ (for now) contain any use cases for event handling. Events are handled by the UI layer calling methods on repositories directly.\n\n## UI Layer\n\nThe [UI layer](https://developer.android.com/topic/architecture/ui-layer) comprises:\n\n\n\n*   UI elements built using [Jetpack Compose](https://developer.android.com/jetpack/compose)\n*   [Android ViewModels](https://developer.android.com/topic/libraries/architecture/viewmodel)\n\nThe ViewModels receive streams of data from use cases and repositories, and transforms them into UI state. The UI elements reflect this state, and provide ways for the user to interact with the app. These interactions are passed as events to the ViewModel where they are processed.\n\n\n![Diagram showing the UI layer architecture](images/architecture-4-ui-layer.png \"Diagram showing the UI layer architecture\")\n\n\n### Modeling UI state\n\nUI state is modeled as a sealed hierarchy using interfaces and immutable data classes. State objects are only ever emitted through the transform of data streams. This approach ensures that:\n\n\n\n*   the UI state always represents the underlying app data - the app data is the source-of-truth.\n*   the UI elements handle all possible states.\n\n**Example: News feed on For You screen**\n\nThe feed (a list) of news resources on the For You screen is modeled using `NewsFeedUiState`. This is a sealed interface which creates a hierarchy of two possible states:\n\n\n\n*   `Loading` indicates that the data is loading\n*   `Success` indicates that the data was loaded successfully. The Success state contains the list of news resources.\n\nThe `feedState` is passed to the `ForYouScreen` composable, which handles both of these states.\n\n\n### Transforming streams into UI state\n\nViewModels receive streams of data as cold [flows](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-flow/index.html) from one or more use cases or repositories. These are [combined](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/combine.html) together, or simply [mapped](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/map.html), to produce a single flow of UI state. This single flow is then converted to a hot flow using [stateIn](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/state-in.html). The conversion to a state flow enables UI elements to read the last known state from the flow.\n\n**Example: Displaying followed topics**\n\nThe `InterestsViewModel` exposes `uiState` as a `StateFlow<InterestsUiState>`. This hot flow is created by obtaining the cold flow of `List<FollowableTopic>` provided by `GetFollowableTopicsUseCase`. Each time a new list is emitted, it is converted into an `InterestsUiState.Interests` state which is exposed to the UI.\n\n\n### Processing user interactions\n\nUser actions are communicated from UI elements to ViewModels using regular method invocations. These methods are passed to the UI elements as lambda expressions.\n\n**Example: Following a topic**\n\nThe `InterestsScreen` takes a lambda expression named `followTopic` which is supplied from `InterestsViewModel.followTopic`. Each time the user taps on a topic to follow this method is called. The ViewModel then processes this action by informing the user data repository.\n\n\n## Further reading\n\n[Guide to app architecture](https://developer.android.com/topic/architecture)\n\n[Jetpack Compose](https://developer.android.com/jetpack/compose)\n"
  },
  {
    "path": "docs/ModularizationLearningJourney.md",
    "content": "# Modularization learning journey\n\nIn this learning journey you will learn about the modularization strategy used\nto create modules in the Now in Android app. For the theory behind modularization, check out\n[the official guidance](https://developer.android.com/topic/modularization).\n\n**IMPORTANT:** Every module has a dependency graph in its README ([example for the app module](https://github.com/android/nowinandroid/tree/main/app)) which can be useful for understanding the overall structure of the project.\n\n## Module types\n\n```mermaid\ngraph TB\n  subgraph :core\n    direction TB\n    :core:data[data]:::android-library\n    :core:database[database]:::android-library\n    :core:model[model]:::jvm-library\n    :core:network[network]:::android-library\n    :core:ui[ui]:::android-library\n  end\n  subgraph :feature \n    direction TB\n    :feature:topic[topic]:::android-feature\n    :feature:foryou[foryou]:::android-feature\n    :feature:interests[interests]:::android-feature\n    :feature:foo[...]:::android-feature\n\n  end\n  :app[app]:::android-application\n\n  :app -.-> :feature:foryou\n  :app -.-> :feature:interests\n  :app -.-> :feature:topic\n  :core:data ---> :core:database\n  :core:data ---> :core:network\n  :core:database ---> :core:model\n  :core:network ---> :core:model\n  :core:ui ---> :core:model\n  :feature:topic -.-> :core:data\n  :feature:topic -.-> :core:ui\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application:::android-application -. implementation .-> feature:::android-feature\n  library:::android-library -- api --> jvm:::jvm-library\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n\n**Top tip**: A module graph (shown above) can be useful during modularization planning for\nvisualizing dependencies between modules.\n\nThe Now in Android app contains the following types of modules:\n\n### The `app` module\nThis contains app level and scaffolding classes that bind the rest of the codebase, such as\n`MainActivity`, `NiaApp` and app-level controlled navigation. A good example of this is the navigation setup through `NiaNavHost` and the bottom navigation bar setup through `TopLevelDestination`. The `app` module depends on all `feature` modules and required `core` modules.\n\n### Feature modules \nThese are feature-specific modules that handle a single responsibility in the app. For example, the `ForYou` feature handles all content and UI state for the \"ForYou\" screen. Feature modules aren't Gradle modules themselves, they are split into two submodules: \n\n* `api` - contains navigation keys\n* `impl` - contains everything else\n\nThis approach allows features to navigate to other features by using the target feature's navigation keys. A feature's `api` and `impl` modules can be used by any app, including test or other flavoured apps. If a class is needed only by one feature module, it should remain within that module. If not, it should be placed into an appropriate `core` module. \n\nA feature's `api` module should not depend on another feature's `api` or `impl` module. A feature's `impl` should only depend on another feature's `api` module. Both submodules should only depend on the `core` modules that they require. \n\n### Core modules \nThese are common library modules containing auxiliary code and specific dependencies that\n  need to be shared between other modules in the app. These modules can depend on other core\n  modules, but they shouldn’t depend on feature nor app modules.\n\n### Miscellaneous modules\nFor example, `sync`, `benchmark` and `test` modules, as well as `app-nia-catalog` - a catalog app for displaying our design system quickly.\n\n## Examples\n\n<table>\n  <tr>\n   <td><strong>Name</strong>\n   </td>\n   <td><strong>Responsibilities</strong>\n   </td>\n   <td><strong>Key classes and good examples</strong>\n   </td>\n  </tr>\n  <tr>\n   <td><code>app</code>\n   </td>\n   <td>Brings everything together required for the app to function correctly. This includes UI scaffolding and navigation. \n   </td>\n   <td><code>NiaApp, MainActivity</code><br>\n   App-level controlled navigation via <code>NiaNavHost, NiaAppState, TopLevelDestination</code>\n   </td>\n  </tr>\n  <tr>\n   <td><code>feature:1:api,</code><br>\n   <code>feature:2:api</code><br>\n   ...\n   </td>\n   <td>Navigation keys and functions that other features can use to navigate to this feature.<br><br>\n   For example: The <code>:topic:api</code> module exposes a <code>Navigator.navigateToTopic</code> function that the\n   <code>:interests:impl</code> module uses to navigate from the <code>InterestsScreen</code> to the <code>TopicScreen</code> when\n   a topic is clicked. \n   </td>\n   <td><code>TopicNavKey</code>\n   </td>\n  </tr>\n  <tr>\n   <td><code>feature:1:impl,</code><br>\n   <code>feature:2:impl</code><br>\n   ...\n   </td>\n   <td>Functionality associated with a specific feature or user journey. Typically contains UI components and ViewModels which read data from other modules.<br>\n   Examples include:<br>\n   <ul>\n      <li><a href=\"https://github.com/android/nowinandroid/tree/main/feature/topic/impl\"><code>feature:topic:impl</code></a> displays information about a topic on the TopicScreen.</li>\n      <li><a href=\"https://github.com/android/nowinandroid/tree/main/feature/foryou/impl\"><code>feature:foryou:impl</code></a> which displays the user's news feed, and onboarding during first run, on the For You screen.</li>\n      </ul>\n   </td>\n   <td><code>TopicScreen</code><br>\n   <code>TopicViewModel</code>\n   </td>\n  </tr>\n  <tr>\n   <td><code>core:data</code>\n   </td>\n   <td>Fetching app data from multiple sources, shared by different features.\n   </td>\n   <td><code>TopicsRepository</code><br>\n   </td>\n  </tr>\n  <tr>\n   <td><code>core:designsystem</code>\n   </td>\n   <td>Design system which includes Core UI components (many of which are customized Material 3 components), app theme and icons. The design system can be viewed by running the <code>app-nia-catalog</code> run configuration. \n   </td>\n   <td>\n   <code>NiaIcons</code>    <code>NiaButton</code>    <code>NiaTheme</code> \n   </td>\n  </tr>\n  <tr>\n   <td><code>core:ui</code>\n   </td>\n   <td>Composite UI components and resources used by feature modules, such as the news feed. Unlike the <code>designsystem</code> module, it is dependent on the data layer since it renders models, like news resources. \n   </td>\n   <td> <code>NewsFeed</code> <code>NewsResourceCardExpanded</code>\n   </td>\n  </tr>\n  <tr>\n   <td><code>core:common</code>\n   </td>\n   <td>Common classes shared between modules.\n   </td>\n   <td><code>NiaDispatchers</code><br>\n   <code>Result</code>\n   </td>\n  </tr>\n  <tr>\n   <td><code>core:network</code>\n   </td>\n   <td>Making network requests and handling responses from a remote data source.\n   </td>\n   <td><code>RetrofitNiaNetworkApi</code>\n   </td>\n  </tr>\n  <tr>\n   <td><code>core:testing</code>\n   </td>\n   <td>Testing dependencies, repositories and util classes.\n   </td>\n   <td><code>NiaTestRunner</code><br>\n   <code>TestDispatcherRule</code>\n   </td>\n  </tr>\n  <tr>\n   <td><code>core:datastore</code>\n   </td>\n   <td>Storing persistent data using DataStore.\n   </td>\n   <td><code>NiaPreferences</code><br>\n   <code>UserPreferencesSerializer</code>\n   </td>\n  </tr>\n  <tr>\n   <td><code>core:database</code>\n   </td>\n   <td>Local database storage using Room.\n   </td>\n   <td><code>NiaDatabase</code><br>\n   <code>DatabaseMigrations</code><br>\n   <code>Dao</code> classes\n   </td>\n  </tr>\n  <tr>\n   <td><code>core:model</code>\n   </td>\n   <td>Model classes used throughout the app.\n   </td>\n   <td><code>Topic</code><br>\n   <code>Episode</code><br>\n   <code>NewsResource</code>\n   </td>\n  </tr>\n</table>\n\n## Dependency graphs\nEach module has its own `README.md` file containing a module graph (e.g. [`:app` module graph](../app/README.md#module-dependency-graph)).  \nWhen modules dependencies change, module graphs are automatically updated by the [Build.yaml](../.github/workflows/Build.yaml) workflow.  \nYou can also manually update the graphs by running the `graphUpdate` task.\n\n## Further considerations\n\nOur modularization approach was defined taking into account the “Now in Android” project roadmap, upcoming work and new features. Additionally, our aim this time around was to find the right balance between overmodularizing a relatively small app and using this opportunity to showcase a modularization pattern fit for a much larger codebase, closer to real world apps in production environments.\n\nThis approach was discussed with the Android community, and evolved taking their feedback into account. With modularization however, there isn’t one right answer that makes all others wrong. Ultimately, there are many ways and approaches to modularizing an app and rarely does one approach fit all purposes, codebases and team preferences. This is why planning beforehand and taking into account all goals, problems you’re trying to solve, future work and predicting potential stepping stones are all crucial steps for defining the best fit structure under your own, unique circumstances. Developers can benefit from a brainstorming session to draw out a graph of modules and dependencies to visualize and plan this better.\n\nOur approach is such an example - we don’t expect it to be an unchangeable structure applicable to all cases, and in fact, it could evolve and change in the future. It’s a general guideline we found to be the best fit for our project and offer it as one example you can further modify, expand and build on top of. One way of doing this would be to increase the granularity of the codebase even more. Granularity is the extent to which your codebase is composed of modules. If your data layer is small, it’s fine to keep it in a single module. But once the number of repositories and data sources starts to grow, it might be worth considering splitting them into separate modules.\n\nWe are also always open to your constructive feedback - learning from the community and exchanging ideas is one of the key elements to improving our guidance.\n\n"
  },
  {
    "path": "feature/bookmarks/api/.gitignore",
    "content": "/build"
  },
  {
    "path": "feature/bookmarks/api/README.md",
    "content": "# `:feature:bookmarks:api`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :feature\n    direction TB\n    subgraph :feature:bookmarks\n      direction TB\n      :feature:bookmarks:api[api]:::android-library\n    end\n  end\n  subgraph :core\n    direction TB\n    :core:navigation[navigation]:::android-library\n  end\n\n  :feature:bookmarks:api --> :core:navigation\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "feature/bookmarks/api/build.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nplugins {\n    alias(libs.plugins.nowinandroid.android.feature.api)\n}\n\nandroid {\n    namespace = \"com.google.samples.apps.nowinandroid.feature.bookmarks.api\"\n}\n"
  },
  {
    "path": "feature/bookmarks/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/api/navigation/BookmarksNavKey.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation\n\nimport androidx.navigation3.runtime.NavKey\nimport kotlinx.serialization.Serializable\n\n@Serializable\nobject BookmarksNavKey : NavKey\n"
  },
  {
    "path": "feature/bookmarks/api/src/main/res/drawable/feature_bookmarks_api_mg_empty_bookmarks.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2023 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    xmlns:aapt=\"http://schemas.android.com/aapt\"\n    android:width=\"64dp\"\n    android:height=\"64dp\"\n    android:viewportWidth=\"64\"\n    android:viewportHeight=\"64\">\n    <path\n        android:pathData=\"M16,2H55C57.209,2 59,3.791 59,6V52\"\n        android:strokeWidth=\"2\"\n        android:fillColor=\"#00000000\"\n        android:strokeLineCap=\"round\">\n        <aapt:attr name=\"android:strokeColor\">\n            <gradient\n                android:startX=\"8\"\n                android:startY=\"8\"\n                android:endX=\"56\"\n                android:endY=\"56\"\n                android:type=\"linear\">\n                <item android:offset=\"0\" android:color=\"#FFFFA8FF\"/>\n                <item android:offset=\"1\" android:color=\"#FFFF8B5E\"/>\n            </gradient>\n        </aapt:attr>\n    </path>\n    <path\n        android:pathData=\"M45,10H9C6.791,10 5,11.791 5,14V55.854C5,59.177 8.817,61.051 11.446,59.019L24.554,48.89C25.995,47.777 28.005,47.777 29.446,48.89L42.554,59.019C45.183,61.051 49,59.177 49,55.854V14C49,11.791 47.209,10 45,10Z\"\n        android:strokeWidth=\"2\"\n        android:fillColor=\"#00000000\"\n        android:strokeLineCap=\"round\">\n        <aapt:attr name=\"android:strokeColor\">\n            <gradient\n                android:startX=\"8\"\n                android:startY=\"8\"\n                android:endX=\"56\"\n                android:endY=\"56\"\n                android:type=\"linear\">\n                <item android:offset=\"0\" android:color=\"#FFFFA8FF\"/>\n                <item android:offset=\"1\" android:color=\"#FFFF8B5E\"/>\n            </gradient>\n        </aapt:attr>\n    </path>\n</vector>\n"
  },
  {
    "path": "feature/bookmarks/api/src/main/res/values/strings.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<resources>\n    <string name=\"feature_bookmarks_api_title\">Saved</string>\n    <string name=\"feature_bookmarks_api_loading\">Loading saved…</string>\n    <string name=\"feature_bookmarks_api_empty_error\">No saved updates</string>\n    <string name=\"feature_bookmarks_api_empty_description\">Updates you save will be stored here\\nto read later</string>\n    <string name=\"feature_bookmarks_api_removed\">Bookmark removed</string>\n    <string name=\"feature_bookmarks_api_undo\">UNDO</string>\n</resources>\n"
  },
  {
    "path": "feature/bookmarks/impl/.gitignore",
    "content": "/build"
  },
  {
    "path": "feature/bookmarks/impl/README.md",
    "content": "# `:feature:bookmarks:impl`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :feature\n    direction TB\n    subgraph :feature:bookmarks\n      direction TB\n      :feature:bookmarks:api[api]:::android-library\n      :feature:bookmarks:impl[impl]:::android-library\n    end\n    subgraph :feature:topic\n      direction TB\n      :feature:topic:api[api]:::android-library\n    end\n  end\n  subgraph :core\n    direction TB\n    :core:analytics[analytics]:::android-library\n    :core:common[common]:::jvm-library\n    :core:data[data]:::android-library\n    :core:database[database]:::android-library\n    :core:datastore[datastore]:::android-library\n    :core:datastore-proto[datastore-proto]:::jvm-library\n    :core:designsystem[designsystem]:::android-library\n    :core:model[model]:::jvm-library\n    :core:navigation[navigation]:::android-library\n    :core:network[network]:::android-library\n    :core:notifications[notifications]:::android-library\n    :core:ui[ui]:::android-library\n  end\n\n  :core:data -.-> :core:analytics\n  :core:data --> :core:common\n  :core:data --> :core:database\n  :core:data --> :core:datastore\n  :core:data --> :core:network\n  :core:data -.-> :core:notifications\n  :core:database --> :core:model\n  :core:datastore -.-> :core:common\n  :core:datastore --> :core:datastore-proto\n  :core:datastore --> :core:model\n  :core:network --> :core:common\n  :core:network --> :core:model\n  :core:notifications -.-> :core:common\n  :core:notifications --> :core:model\n  :core:ui --> :core:analytics\n  :core:ui --> :core:designsystem\n  :core:ui --> :core:model\n  :feature:bookmarks:api --> :core:navigation\n  :feature:bookmarks:impl -.-> :core:data\n  :feature:bookmarks:impl -.-> :core:designsystem\n  :feature:bookmarks:impl -.-> :core:ui\n  :feature:bookmarks:impl -.-> :feature:bookmarks:api\n  :feature:bookmarks:impl -.-> :feature:topic:api\n  :feature:topic:api -.-> :core:designsystem\n  :feature:topic:api --> :core:navigation\n  :feature:topic:api -.-> :core:ui\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "feature/bookmarks/impl/build.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nplugins {\n    alias(libs.plugins.nowinandroid.android.feature.impl)\n    alias(libs.plugins.nowinandroid.android.library.compose)\n}\n\nandroid {\n    namespace = \"com.google.samples.apps.nowinandroid.feature.bookmarks.impl\"\n}\n\ndependencies {\n    implementation(projects.core.data)\n    implementation(projects.feature.bookmarks.api)\n    implementation(projects.feature.topic.api)\n\n    testImplementation(projects.core.testing)\n\n    androidTestImplementation(libs.bundles.androidx.compose.ui.test)\n    androidTestImplementation(projects.core.testing)\n}\n"
  },
  {
    "path": "feature/bookmarks/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksScreenTest.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.bookmarks.impl\n\nimport androidx.activity.ComponentActivity\nimport androidx.compose.runtime.CompositionLocalProvider\nimport androidx.compose.ui.test.assertCountEquals\nimport androidx.compose.ui.test.assertHasClickAction\nimport androidx.compose.ui.test.filter\nimport androidx.compose.ui.test.hasAnyAncestor\nimport androidx.compose.ui.test.hasScrollToNodeAction\nimport androidx.compose.ui.test.hasText\nimport androidx.compose.ui.test.junit4.createAndroidComposeRule\nimport androidx.compose.ui.test.onAllNodesWithContentDescription\nimport androidx.compose.ui.test.onFirst\nimport androidx.compose.ui.test.onNodeWithContentDescription\nimport androidx.compose.ui.test.onNodeWithText\nimport androidx.compose.ui.test.performClick\nimport androidx.compose.ui.test.performScrollToNode\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.compose.LocalLifecycleOwner\nimport androidx.lifecycle.testing.TestLifecycleOwner\nimport com.google.samples.apps.nowinandroid.core.testing.data.userNewsResourcesTestData\nimport com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState\nimport com.google.samples.apps.nowinandroid.feature.bookmarks.api.R\nimport kotlinx.coroutines.test.runTest\nimport org.junit.Rule\nimport org.junit.Test\nimport kotlin.test.assertEquals\nimport kotlin.test.assertTrue\n\n/**\n * UI tests for [BookmarksScreen] composable.\n */\nclass BookmarksScreenTest {\n\n    @get:Rule\n    val composeTestRule = createAndroidComposeRule<ComponentActivity>()\n\n    @Test\n    fun loading_showsLoadingSpinner() {\n        composeTestRule.setContent {\n            BookmarksScreen(\n                feedState = NewsFeedUiState.Loading,\n                onShowSnackbar = { _, _ -> false },\n                removeFromBookmarks = {},\n                onTopicClick = {},\n                onNewsResourceViewed = {},\n            )\n        }\n\n        composeTestRule\n            .onNodeWithContentDescription(\n                composeTestRule.activity.resources.getString(R.string.feature_bookmarks_api_loading),\n            )\n            .assertExists()\n    }\n\n    @Test\n    fun feed_whenHasBookmarks_showsBookmarks() {\n        composeTestRule.setContent {\n            BookmarksScreen(\n                feedState = NewsFeedUiState.Success(\n                    userNewsResourcesTestData.take(2),\n                ),\n                onShowSnackbar = { _, _ -> false },\n                removeFromBookmarks = {},\n                onTopicClick = {},\n                onNewsResourceViewed = {},\n            )\n        }\n\n        composeTestRule\n            .onNodeWithText(\n                userNewsResourcesTestData[0].title,\n                substring = true,\n            )\n            .assertExists()\n            .assertHasClickAction()\n\n        composeTestRule.onNode(hasScrollToNodeAction())\n            .performScrollToNode(\n                hasText(\n                    userNewsResourcesTestData[1].title,\n                    substring = true,\n                ),\n            )\n\n        composeTestRule\n            .onNodeWithText(\n                userNewsResourcesTestData[1].title,\n                substring = true,\n            )\n            .assertExists()\n            .assertHasClickAction()\n    }\n\n    @Test\n    fun feed_whenRemovingBookmark_removesBookmark() {\n        var removeFromBookmarksCalled = false\n\n        composeTestRule.setContent {\n            BookmarksScreen(\n                feedState = NewsFeedUiState.Success(\n                    userNewsResourcesTestData.take(2),\n                ),\n                onShowSnackbar = { _, _ -> false },\n                removeFromBookmarks = { newsResourceId ->\n                    assertEquals(userNewsResourcesTestData[0].id, newsResourceId)\n                    removeFromBookmarksCalled = true\n                },\n                onTopicClick = {},\n                onNewsResourceViewed = {},\n            )\n        }\n\n        composeTestRule\n            .onAllNodesWithContentDescription(\n                composeTestRule.activity.getString(\n                    com.google.samples.apps.nowinandroid.core.ui.R.string.core_ui_unbookmark,\n                ),\n            ).filter(\n                hasAnyAncestor(\n                    hasText(\n                        userNewsResourcesTestData[0].title,\n                        substring = true,\n                    ),\n                ),\n            )\n            .assertCountEquals(1)\n            .onFirst()\n            .performClick()\n\n        assertTrue(removeFromBookmarksCalled)\n    }\n\n    @Test\n    fun feed_whenHasNoBookmarks_showsEmptyState() {\n        composeTestRule.setContent {\n            BookmarksScreen(\n                feedState = NewsFeedUiState.Success(emptyList()),\n                onShowSnackbar = { _, _ -> false },\n                removeFromBookmarks = {},\n                onTopicClick = {},\n                onNewsResourceViewed = {},\n            )\n        }\n\n        composeTestRule\n            .onNodeWithText(\n                composeTestRule.activity.getString(R.string.feature_bookmarks_api_empty_error),\n            )\n            .assertExists()\n\n        composeTestRule\n            .onNodeWithText(\n                composeTestRule.activity.getString(R.string.feature_bookmarks_api_empty_description),\n            )\n            .assertExists()\n    }\n\n    @Test\n    fun feed_whenLifecycleStops_undoBookmarkedStateIsCleared() = runTest {\n        var undoStateCleared = false\n        val testLifecycleOwner = TestLifecycleOwner(initialState = Lifecycle.State.STARTED)\n\n        composeTestRule.setContent {\n            CompositionLocalProvider(LocalLifecycleOwner provides testLifecycleOwner) {\n                BookmarksScreen(\n                    feedState = NewsFeedUiState.Success(emptyList()),\n                    onShowSnackbar = { _, _ -> false },\n                    removeFromBookmarks = {},\n                    onTopicClick = {},\n                    onNewsResourceViewed = {},\n                    clearUndoState = {\n                        undoStateCleared = true\n                    },\n                )\n            }\n        }\n\n        assertEquals(false, undoStateCleared)\n        testLifecycleOwner.handleLifecycleEvent(event = Lifecycle.Event.ON_STOP)\n        assertEquals(true, undoStateCleared)\n    }\n}\n"
  },
  {
    "path": "feature/bookmarks/impl/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<manifest />\n"
  },
  {
    "path": "feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksScreen.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.bookmarks.impl\n\nimport androidx.annotation.VisibleForTesting\nimport androidx.compose.foundation.Image\nimport androidx.compose.foundation.gestures.Orientation\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.PaddingValues\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.WindowInsets\nimport androidx.compose.foundation.layout.fillMaxHeight\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.safeDrawing\nimport androidx.compose.foundation.layout.systemBars\nimport androidx.compose.foundation.layout.windowInsetsBottomHeight\nimport androidx.compose.foundation.layout.windowInsetsPadding\nimport androidx.compose.foundation.layout.wrapContentSize\nimport androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid\nimport androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells\nimport androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan\nimport androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.getValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.graphics.ColorFilter\nimport androidx.compose.ui.platform.testTag\nimport androidx.compose.ui.res.painterResource\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.text.style.TextAlign\nimport androidx.compose.ui.tooling.preview.Preview\nimport androidx.compose.ui.tooling.preview.PreviewParameter\nimport androidx.compose.ui.unit.dp\nimport androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.compose.LifecycleEventEffect\nimport androidx.lifecycle.compose.collectAsStateWithLifecycle\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.DraggableScrollbar\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.rememberDraggableScroller\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.scrollbarState\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalTintTheme\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\nimport com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource\nimport com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState\nimport com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading\nimport com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success\nimport com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent\nimport com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank\nimport com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider\nimport com.google.samples.apps.nowinandroid.core.ui.newsFeed\nimport com.google.samples.apps.nowinandroid.feature.bookmarks.api.R\n\n@Composable\ninternal fun BookmarksScreen(\n    onTopicClick: (String) -> Unit,\n    onShowSnackbar: suspend (String, String?) -> Boolean,\n    modifier: Modifier = Modifier,\n    viewModel: BookmarksViewModel = hiltViewModel(),\n) {\n    val feedState by viewModel.feedUiState.collectAsStateWithLifecycle()\n    BookmarksScreen(\n        feedState = feedState,\n        onShowSnackbar = onShowSnackbar,\n        removeFromBookmarks = viewModel::removeFromSavedResources,\n        onNewsResourceViewed = { viewModel.setNewsResourceViewed(it, true) },\n        onTopicClick = onTopicClick,\n        modifier = modifier,\n        shouldDisplayUndoBookmark = viewModel.shouldDisplayUndoBookmark,\n        undoBookmarkRemoval = viewModel::undoBookmarkRemoval,\n        clearUndoState = viewModel::clearUndoState,\n    )\n}\n\n/**\n * Displays the user's bookmarked articles. Includes support for loading and empty states.\n */\n@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)\n@Composable\ninternal fun BookmarksScreen(\n    feedState: NewsFeedUiState,\n    onShowSnackbar: suspend (String, String?) -> Boolean,\n    removeFromBookmarks: (String) -> Unit,\n    onNewsResourceViewed: (String) -> Unit,\n    onTopicClick: (String) -> Unit,\n    modifier: Modifier = Modifier,\n    shouldDisplayUndoBookmark: Boolean = false,\n    undoBookmarkRemoval: () -> Unit = {},\n    clearUndoState: () -> Unit = {},\n) {\n    val bookmarkRemovedMessage = stringResource(id = R.string.feature_bookmarks_api_removed)\n    val undoText = stringResource(id = R.string.feature_bookmarks_api_undo)\n\n    LaunchedEffect(shouldDisplayUndoBookmark) {\n        if (shouldDisplayUndoBookmark) {\n            val snackBarResult = onShowSnackbar(bookmarkRemovedMessage, undoText)\n            if (snackBarResult) {\n                undoBookmarkRemoval()\n            } else {\n                clearUndoState()\n            }\n        }\n    }\n\n    LifecycleEventEffect(Lifecycle.Event.ON_STOP) {\n        clearUndoState()\n    }\n\n    when (feedState) {\n        Loading -> LoadingState(modifier)\n        is Success -> if (feedState.feed.isNotEmpty()) {\n            BookmarksGrid(\n                feedState,\n                removeFromBookmarks,\n                onNewsResourceViewed,\n                onTopicClick,\n                modifier,\n            )\n        } else {\n            EmptyState(modifier)\n        }\n    }\n\n    TrackScreenViewEvent(screenName = \"Saved\")\n}\n\n@Composable\nprivate fun LoadingState(modifier: Modifier = Modifier) {\n    NiaLoadingWheel(\n        modifier = modifier\n            .fillMaxWidth()\n            .wrapContentSize()\n            .testTag(\"bookmarks:loading\"),\n        contentDesc = stringResource(id = R.string.feature_bookmarks_api_loading),\n    )\n}\n\n@Composable\nprivate fun BookmarksGrid(\n    feedState: NewsFeedUiState,\n    removeFromBookmarks: (String) -> Unit,\n    onNewsResourceViewed: (String) -> Unit,\n    onTopicClick: (String) -> Unit,\n    modifier: Modifier = Modifier,\n) {\n    val scrollableState = rememberLazyStaggeredGridState()\n    TrackScrollJank(scrollableState = scrollableState, stateName = \"bookmarks:grid\")\n    Box(\n        modifier = modifier\n            .fillMaxSize(),\n    ) {\n        LazyVerticalStaggeredGrid(\n            columns = StaggeredGridCells.Adaptive(300.dp),\n            contentPadding = PaddingValues(16.dp),\n            horizontalArrangement = Arrangement.spacedBy(16.dp),\n            verticalItemSpacing = 24.dp,\n            state = scrollableState,\n            modifier = Modifier\n                .fillMaxSize()\n                .testTag(\"bookmarks:feed\"),\n        ) {\n            newsFeed(\n                feedState = feedState,\n                onNewsResourcesCheckedChanged = { id, _ -> removeFromBookmarks(id) },\n                onNewsResourceViewed = onNewsResourceViewed,\n                onTopicClick = onTopicClick,\n            )\n            item(span = StaggeredGridItemSpan.FullLine) {\n                Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))\n            }\n        }\n        val itemsAvailable = when (feedState) {\n            Loading -> 1\n            is Success -> feedState.feed.size\n        }\n        val scrollbarState = scrollableState.scrollbarState(\n            itemsAvailable = itemsAvailable,\n        )\n        scrollableState.DraggableScrollbar(\n            modifier = Modifier\n                .fillMaxHeight()\n                .windowInsetsPadding(WindowInsets.systemBars)\n                .padding(horizontal = 2.dp)\n                .align(Alignment.CenterEnd),\n            state = scrollbarState,\n            orientation = Orientation.Vertical,\n            onThumbMoved = scrollableState.rememberDraggableScroller(\n                itemsAvailable = itemsAvailable,\n            ),\n        )\n    }\n}\n\n@Composable\nprivate fun EmptyState(modifier: Modifier = Modifier) {\n    Column(\n        modifier = modifier\n            .padding(16.dp)\n            .fillMaxSize()\n            .testTag(\"bookmarks:empty\"),\n        verticalArrangement = Arrangement.Center,\n        horizontalAlignment = Alignment.CenterHorizontally,\n    ) {\n        val iconTint = LocalTintTheme.current.iconTint\n        Image(\n            modifier = Modifier.fillMaxWidth(),\n            painter = painterResource(id = R.drawable.feature_bookmarks_api_mg_empty_bookmarks),\n            colorFilter = if (iconTint != Color.Unspecified) ColorFilter.tint(iconTint) else null,\n            contentDescription = null,\n        )\n\n        Spacer(modifier = Modifier.height(48.dp))\n\n        Text(\n            text = stringResource(id = R.string.feature_bookmarks_api_empty_error),\n            modifier = Modifier.fillMaxWidth(),\n            textAlign = TextAlign.Center,\n            style = MaterialTheme.typography.titleMedium,\n            fontWeight = FontWeight.Bold,\n        )\n\n        Spacer(modifier = Modifier.height(8.dp))\n\n        Text(\n            text = stringResource(id = R.string.feature_bookmarks_api_empty_description),\n            modifier = Modifier.fillMaxWidth(),\n            textAlign = TextAlign.Center,\n            style = MaterialTheme.typography.bodyMedium,\n        )\n    }\n}\n\n@Preview\n@Composable\nprivate fun LoadingStatePreview() {\n    NiaTheme {\n        LoadingState()\n    }\n}\n\n@Preview\n@Composable\nprivate fun BookmarksGridPreview(\n    @PreviewParameter(UserNewsResourcePreviewParameterProvider::class)\n    userNewsResources: List<UserNewsResource>,\n) {\n    NiaTheme {\n        BookmarksGrid(\n            feedState = Success(userNewsResources),\n            removeFromBookmarks = {},\n            onNewsResourceViewed = {},\n            onTopicClick = {},\n        )\n    }\n}\n\n@Preview\n@Composable\nprivate fun EmptyStatePreview() {\n    NiaTheme {\n        EmptyState()\n    }\n}\n"
  },
  {
    "path": "feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksViewModel.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.bookmarks.impl\n\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.setValue\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository\nimport com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource\nimport com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState\nimport com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport kotlinx.coroutines.flow.SharingStarted\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.map\nimport kotlinx.coroutines.flow.onStart\nimport kotlinx.coroutines.flow.stateIn\nimport kotlinx.coroutines.launch\nimport javax.inject.Inject\n\n@HiltViewModel\nclass BookmarksViewModel @Inject constructor(\n    private val userDataRepository: UserDataRepository,\n    userNewsResourceRepository: UserNewsResourceRepository,\n) : ViewModel() {\n\n    var shouldDisplayUndoBookmark by mutableStateOf(false)\n    private var lastRemovedBookmarkId: String? = null\n\n    val feedUiState: StateFlow<NewsFeedUiState> =\n        userNewsResourceRepository.observeAllBookmarked()\n            .map<List<UserNewsResource>, NewsFeedUiState>(NewsFeedUiState::Success)\n            .onStart { emit(Loading) }\n            .stateIn(\n                scope = viewModelScope,\n                started = SharingStarted.WhileSubscribed(5_000),\n                initialValue = Loading,\n            )\n\n    fun removeFromSavedResources(newsResourceId: String) {\n        viewModelScope.launch {\n            shouldDisplayUndoBookmark = true\n            lastRemovedBookmarkId = newsResourceId\n            userDataRepository.setNewsResourceBookmarked(newsResourceId, false)\n        }\n    }\n\n    fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) {\n        viewModelScope.launch {\n            userDataRepository.setNewsResourceViewed(newsResourceId, viewed)\n        }\n    }\n\n    fun undoBookmarkRemoval() {\n        viewModelScope.launch {\n            lastRemovedBookmarkId?.let {\n                userDataRepository.setNewsResourceBookmarked(it, true)\n            }\n        }\n        clearUndoState()\n    }\n\n    fun clearUndoState() {\n        shouldDisplayUndoBookmark = false\n        lastRemovedBookmarkId = null\n    }\n}\n"
  },
  {
    "path": "feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/navigation/BookmarksEntryProvider.kt",
    "content": "/*\n * Copyright 2025 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation\n\nimport androidx.compose.material3.SnackbarDuration.Short\nimport androidx.compose.material3.SnackbarHostState\nimport androidx.compose.material3.SnackbarResult.ActionPerformed\nimport androidx.compose.runtime.compositionLocalOf\nimport androidx.navigation3.runtime.EntryProviderScope\nimport androidx.navigation3.runtime.NavKey\nimport com.google.samples.apps.nowinandroid.core.navigation.Navigator\nimport com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation.BookmarksNavKey\nimport com.google.samples.apps.nowinandroid.feature.bookmarks.impl.BookmarksScreen\nimport com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic\n\nfun EntryProviderScope<NavKey>.bookmarksEntry(navigator: Navigator) {\n    entry<BookmarksNavKey> {\n        val snackbarHostState = LocalSnackbarHostState.current\n        BookmarksScreen(\n            onTopicClick = navigator::navigateToTopic,\n            onShowSnackbar = { message, action ->\n                snackbarHostState.showSnackbar(\n                    message = message,\n                    actionLabel = action,\n                    duration = Short,\n                ) == ActionPerformed\n            },\n        )\n    }\n}\n\n// TODO: Why is this here?\nval LocalSnackbarHostState = compositionLocalOf<SnackbarHostState> {\n    error(\"SnackbarHostState state should be initialized at runtime\")\n}\n"
  },
  {
    "path": "feature/bookmarks/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksViewModelTest.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.bookmarks.impl\n\nimport com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository\nimport com.google.samples.apps.nowinandroid.core.testing.data.newsResourcesTestData\nimport com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository\nimport com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository\nimport com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule\nimport com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading\nimport com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success\nimport com.google.samples.apps.nowinandroid.feature.bookmarks.impl.BookmarksViewModel\nimport kotlinx.coroutines.flow.collect\nimport kotlinx.coroutines.launch\nimport kotlinx.coroutines.test.UnconfinedTestDispatcher\nimport kotlinx.coroutines.test.runTest\nimport org.junit.Before\nimport org.junit.Rule\nimport org.junit.Test\nimport kotlin.test.assertEquals\nimport kotlin.test.assertFalse\nimport kotlin.test.assertIs\nimport kotlin.test.assertTrue\n\n/**\n * To learn more about how this test handles Flows created with stateIn, see\n * https://developer.android.com/kotlin/flow/test#statein\n */\nclass BookmarksViewModelTest {\n    @get:Rule\n    val dispatcherRule = MainDispatcherRule()\n\n    private val userDataRepository = TestUserDataRepository()\n    private val newsRepository = TestNewsRepository()\n    private val userNewsResourceRepository = CompositeUserNewsResourceRepository(\n        newsRepository = newsRepository,\n        userDataRepository = userDataRepository,\n    )\n    private lateinit var viewModel: BookmarksViewModel\n\n    @Before\n    fun setup() {\n        viewModel = BookmarksViewModel(\n            userDataRepository = userDataRepository,\n            userNewsResourceRepository = userNewsResourceRepository,\n        )\n    }\n\n    @Test\n    fun stateIsInitiallyLoading() = runTest {\n        assertEquals(Loading, viewModel.feedUiState.value)\n    }\n\n    @Test\n    fun oneBookmark_showsInFeed() = runTest {\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.feedUiState.collect() }\n\n        newsRepository.sendNewsResources(newsResourcesTestData)\n        userDataRepository.setNewsResourceBookmarked(newsResourcesTestData[0].id, true)\n        val item = viewModel.feedUiState.value\n        assertIs<Success>(item)\n        assertEquals(item.feed.size, 1)\n    }\n\n    @Test\n    fun oneBookmark_whenRemoving_removesFromFeed() = runTest {\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.feedUiState.collect() }\n        // Set the news resources to be used by this test\n        newsRepository.sendNewsResources(newsResourcesTestData)\n        // Start with the resource saved\n        userDataRepository.setNewsResourceBookmarked(newsResourcesTestData[0].id, true)\n        // Use viewModel to remove saved resource\n        viewModel.removeFromSavedResources(newsResourcesTestData[0].id)\n        // Verify list of saved resources is now empty\n        val item = viewModel.feedUiState.value\n        assertIs<Success>(item)\n        assertEquals(item.feed.size, 0)\n        assertTrue(viewModel.shouldDisplayUndoBookmark)\n    }\n\n    @Test\n    fun feedUiState_resourceIsViewed_setResourcesViewed() = runTest {\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.feedUiState.collect() }\n\n        // Given\n        newsRepository.sendNewsResources(newsResourcesTestData)\n        userDataRepository.setNewsResourceBookmarked(newsResourcesTestData[0].id, true)\n        val itemBeforeViewed = viewModel.feedUiState.value\n        assertIs<Success>(itemBeforeViewed)\n        assertFalse(itemBeforeViewed.feed.first().hasBeenViewed)\n\n        // When\n        viewModel.setNewsResourceViewed(newsResourcesTestData[0].id, true)\n\n        // Then\n        val item = viewModel.feedUiState.value\n        assertIs<Success>(item)\n        assertTrue(item.feed.first().hasBeenViewed)\n    }\n\n    @Test\n    fun feedUiState_undoneBookmarkRemoval_bookmarkIsRestored() = runTest {\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.feedUiState.collect() }\n\n        // Given\n        newsRepository.sendNewsResources(newsResourcesTestData)\n        userDataRepository.setNewsResourceBookmarked(newsResourcesTestData[0].id, true)\n        viewModel.removeFromSavedResources(newsResourcesTestData[0].id)\n        assertTrue(viewModel.shouldDisplayUndoBookmark)\n        val itemBeforeUndo = viewModel.feedUiState.value\n        assertIs<Success>(itemBeforeUndo)\n        assertEquals(0, itemBeforeUndo.feed.size)\n\n        // When\n        viewModel.undoBookmarkRemoval()\n\n        // Then\n        assertFalse(viewModel.shouldDisplayUndoBookmark)\n        val item = viewModel.feedUiState.value\n        assertIs<Success>(item)\n        assertEquals(1, item.feed.size)\n    }\n}\n"
  },
  {
    "path": "feature/foryou/api/.gitignore",
    "content": "/build"
  },
  {
    "path": "feature/foryou/api/README.md",
    "content": "# `:feature:foryou:api`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :feature\n    direction TB\n    subgraph :feature:foryou\n      direction TB\n      :feature:foryou:api[api]:::android-library\n    end\n  end\n  subgraph :core\n    direction TB\n    :core:navigation[navigation]:::android-library\n  end\n\n  :feature:foryou:api --> :core:navigation\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "feature/foryou/api/build.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nplugins {\n    alias(libs.plugins.nowinandroid.android.feature.api)\n}\n\nandroid {\n    namespace = \"com.google.samples.apps.nowinandroid.feature.foryou.api\"\n}\n\ndependencies {\n    api(projects.core.navigation)\n}\n"
  },
  {
    "path": "feature/foryou/api/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<manifest />\n"
  },
  {
    "path": "feature/foryou/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/api/navigation/ForYouNavKey.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.foryou.api.navigation\n\nimport androidx.navigation3.runtime.NavKey\nimport kotlinx.serialization.Serializable\n\n@Serializable\nobject ForYouNavKey : NavKey\n"
  },
  {
    "path": "feature/foryou/api/src/main/res/drawable/feature_foryou_api_ic_icon_placeholder.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"64dp\"\n    android:height=\"64dp\"\n    android:viewportWidth=\"64\"\n    android:viewportHeight=\"64\">\n    <path\n        android:fillColor=\"#FCFCFC\"\n        android:pathData=\"M32,32m-32,0a32,32 0,1 1,64 0a32,32 0,1 1,-64 0\" />\n    <path\n        android:fillAlpha=\"0.02\"\n        android:fillColor=\"#7E7576\"\n        android:pathData=\"M32,32m-32,0a32,32 0,1 1,64 0a32,32 0,1 1,-64 0\" />\n    <path\n        android:fillAlpha=\"0.08\"\n        android:fillColor=\"#8C4190\"\n        android:pathData=\"M32,32m-32,0a32,32 0,1 1,64 0a32,32 0,1 1,-64 0\" />\n</vector>\n"
  },
  {
    "path": "feature/foryou/api/src/main/res/values/strings.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2021 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<resources>\n    <string name=\"feature_foryou_api_title\">For you</string>\n    <string name=\"feature_foryou_api_done\">Done</string>\n    <string name=\"feature_foryou_api_loading\">Loading for you…</string>\n    <string name=\"feature_foryou_api_navigate_up\">Navigate up</string>\n    <string name=\"feature_foryou_api_onboarding_guidance_title\">What are you interested in?</string>\n    <string name=\"feature_foryou_api_onboarding_guidance_subtitle\">Updates from topics you follow will appear here. Follow some things to get started.</string>\n</resources>\n"
  },
  {
    "path": "feature/foryou/impl/.gitignore",
    "content": "/build"
  },
  {
    "path": "feature/foryou/impl/README.md",
    "content": "# `:feature:foryou:impl`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :feature\n    direction TB\n    subgraph :feature:foryou\n      direction TB\n      :feature:foryou:api[api]:::android-library\n      :feature:foryou:impl[impl]:::android-library\n    end\n    subgraph :feature:topic\n      direction TB\n      :feature:topic:api[api]:::android-library\n    end\n  end\n  subgraph :core\n    direction TB\n    :core:analytics[analytics]:::android-library\n    :core:common[common]:::jvm-library\n    :core:data[data]:::android-library\n    :core:database[database]:::android-library\n    :core:datastore[datastore]:::android-library\n    :core:datastore-proto[datastore-proto]:::jvm-library\n    :core:designsystem[designsystem]:::android-library\n    :core:domain[domain]:::android-library\n    :core:model[model]:::jvm-library\n    :core:navigation[navigation]:::android-library\n    :core:network[network]:::android-library\n    :core:notifications[notifications]:::android-library\n    :core:ui[ui]:::android-library\n  end\n\n  :core:data -.-> :core:analytics\n  :core:data --> :core:common\n  :core:data --> :core:database\n  :core:data --> :core:datastore\n  :core:data --> :core:network\n  :core:data -.-> :core:notifications\n  :core:database --> :core:model\n  :core:datastore -.-> :core:common\n  :core:datastore --> :core:datastore-proto\n  :core:datastore --> :core:model\n  :core:domain --> :core:data\n  :core:domain --> :core:model\n  :core:network --> :core:common\n  :core:network --> :core:model\n  :core:notifications -.-> :core:common\n  :core:notifications --> :core:model\n  :core:ui --> :core:analytics\n  :core:ui --> :core:designsystem\n  :core:ui --> :core:model\n  :feature:foryou:api --> :core:navigation\n  :feature:foryou:impl -.-> :core:designsystem\n  :feature:foryou:impl -.-> :core:domain\n  :feature:foryou:impl -.-> :core:notifications\n  :feature:foryou:impl -.-> :core:ui\n  :feature:foryou:impl -.-> :feature:foryou:api\n  :feature:foryou:impl -.-> :feature:topic:api\n  :feature:topic:api -.-> :core:designsystem\n  :feature:topic:api --> :core:navigation\n  :feature:topic:api -.-> :core:ui\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "feature/foryou/impl/build.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nplugins {\n    alias(libs.plugins.nowinandroid.android.feature.impl)\n    alias(libs.plugins.nowinandroid.android.library.compose)\n    alias(libs.plugins.roborazzi)\n}\n\nandroid {\n    namespace = \"com.google.samples.apps.nowinandroid.feature.foryou.impl\"\n    testOptions.unitTests.isIncludeAndroidResources = true\n}\n\ndependencies {\n    implementation(libs.accompanist.permissions)\n    implementation(projects.core.domain)\n    implementation(projects.core.notifications)\n    implementation(projects.feature.foryou.api)\n    implementation(projects.feature.topic.api)\n    implementation(libs.androidx.activity.compose)\n\n    testImplementation(libs.hilt.android.testing)\n    testImplementation(libs.robolectric)\n    testImplementation(projects.core.testing)\n    testDemoImplementation(projects.core.screenshotTesting)\n\n    androidTestImplementation(libs.bundles.androidx.compose.ui.test)\n    androidTestImplementation(projects.core.testing)\n}\n"
  },
  {
    "path": "feature/foryou/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreenTest.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.foryou.impl\n\nimport androidx.activity.ComponentActivity\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.ui.test.assertHasClickAction\nimport androidx.compose.ui.test.assertIsEnabled\nimport androidx.compose.ui.test.assertIsNotEnabled\nimport androidx.compose.ui.test.hasScrollToNodeAction\nimport androidx.compose.ui.test.hasText\nimport androidx.compose.ui.test.junit4.createAndroidComposeRule\nimport androidx.compose.ui.test.onFirst\nimport androidx.compose.ui.test.onNodeWithContentDescription\nimport androidx.compose.ui.test.onNodeWithText\nimport androidx.compose.ui.test.performScrollToNode\nimport com.google.samples.apps.nowinandroid.core.rules.GrantPostNotificationsPermissionRule\nimport com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData\nimport com.google.samples.apps.nowinandroid.core.testing.data.userNewsResourcesTestData\nimport com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState\nimport com.google.samples.apps.nowinandroid.feature.foryou.api.R\nimport org.junit.Rule\nimport org.junit.Test\n\nclass ForYouScreenTest {\n\n    @get:Rule(order = 0)\n    val postNotificationsPermission = GrantPostNotificationsPermissionRule()\n\n    @get:Rule(order = 1)\n    val composeTestRule = createAndroidComposeRule<ComponentActivity>()\n\n    private val doneButtonMatcher by lazy {\n        hasText(\n            composeTestRule.activity.resources.getString(R.string.feature_foryou_api_done),\n        )\n    }\n\n    @Test\n    fun circularProgressIndicator_whenScreenIsLoading_exists() {\n        composeTestRule.setContent {\n            Box {\n                ForYouScreen(\n                    isSyncing = false,\n                    onboardingUiState = OnboardingUiState.Loading,\n                    feedState = NewsFeedUiState.Loading,\n                    deepLinkedUserNewsResource = null,\n                    onTopicCheckedChanged = { _, _ -> },\n                    onTopicClick = {},\n                    saveFollowedTopics = {},\n                    onNewsResourcesCheckedChanged = { _, _ -> },\n                    onNewsResourceViewed = {},\n                    onDeepLinkOpened = {},\n                )\n            }\n        }\n\n        composeTestRule\n            .onNodeWithContentDescription(\n                composeTestRule.activity.resources.getString(R.string.feature_foryou_api_loading),\n            )\n            .assertExists()\n    }\n\n    @Test\n    fun circularProgressIndicator_whenScreenIsSyncing_exists() {\n        composeTestRule.setContent {\n            Box {\n                ForYouScreen(\n                    isSyncing = true,\n                    onboardingUiState = OnboardingUiState.NotShown,\n                    feedState = NewsFeedUiState.Success(emptyList()),\n                    deepLinkedUserNewsResource = null,\n                    onTopicCheckedChanged = { _, _ -> },\n                    onTopicClick = {},\n                    saveFollowedTopics = {},\n                    onNewsResourcesCheckedChanged = { _, _ -> },\n                    onNewsResourceViewed = {},\n                    onDeepLinkOpened = {},\n                )\n            }\n        }\n\n        composeTestRule\n            .onNodeWithContentDescription(\n                composeTestRule.activity.resources.getString(R.string.feature_foryou_api_loading),\n            )\n            .assertExists()\n    }\n\n    @Test\n    fun topicSelector_whenNoTopicsSelected_showsTopicChipsAndDisabledDoneButton() {\n        val testData = followableTopicTestData.map { it.copy(isFollowed = false) }\n\n        composeTestRule.setContent {\n            Box {\n                ForYouScreen(\n                    isSyncing = false,\n                    onboardingUiState = OnboardingUiState.Shown(\n                        topics = testData,\n                    ),\n                    feedState = NewsFeedUiState.Success(\n                        feed = emptyList(),\n                    ),\n                    deepLinkedUserNewsResource = null,\n                    onTopicCheckedChanged = { _, _ -> },\n                    onTopicClick = {},\n                    saveFollowedTopics = {},\n                    onNewsResourcesCheckedChanged = { _, _ -> },\n                    onNewsResourceViewed = {},\n                    onDeepLinkOpened = {},\n                )\n            }\n        }\n\n        testData.forEach { testTopic ->\n            composeTestRule\n                .onNodeWithText(testTopic.topic.name)\n                .assertExists()\n                .assertHasClickAction()\n        }\n\n        // Scroll until the Done button is visible\n        composeTestRule\n            .onAllNodes(hasScrollToNodeAction())\n            .onFirst()\n            .performScrollToNode(doneButtonMatcher)\n\n        composeTestRule\n            .onNode(doneButtonMatcher)\n            .assertExists()\n            .assertIsNotEnabled()\n            .assertHasClickAction()\n    }\n\n    @Test\n    fun topicSelector_whenSomeTopicsSelected_showsTopicChipsAndEnabledDoneButton() {\n        composeTestRule.setContent {\n            Box {\n                ForYouScreen(\n                    isSyncing = false,\n                    onboardingUiState =\n                    OnboardingUiState.Shown(\n                        // Follow one topic\n                        topics = followableTopicTestData.mapIndexed { index, testTopic ->\n                            testTopic.copy(isFollowed = index == 1)\n                        },\n                    ),\n                    feedState = NewsFeedUiState.Success(\n                        feed = emptyList(),\n                    ),\n                    deepLinkedUserNewsResource = null,\n                    onTopicCheckedChanged = { _, _ -> },\n                    onTopicClick = {},\n                    saveFollowedTopics = {},\n                    onNewsResourcesCheckedChanged = { _, _ -> },\n                    onNewsResourceViewed = {},\n                    onDeepLinkOpened = {},\n                )\n            }\n        }\n\n        followableTopicTestData.forEach { testTopic ->\n            composeTestRule\n                .onNodeWithText(testTopic.topic.name)\n                .assertExists()\n                .assertHasClickAction()\n        }\n\n        // Scroll until the Done button is visible\n        composeTestRule\n            .onAllNodes(hasScrollToNodeAction())\n            .onFirst()\n            .performScrollToNode(doneButtonMatcher)\n\n        composeTestRule\n            .onNode(doneButtonMatcher)\n            .assertExists()\n            .assertIsEnabled()\n            .assertHasClickAction()\n    }\n\n    @Test\n    fun feed_whenInterestsSelectedAndLoading_showsLoadingIndicator() {\n        composeTestRule.setContent {\n            Box {\n                ForYouScreen(\n                    isSyncing = false,\n                    onboardingUiState =\n                    OnboardingUiState.Shown(\n                        topics = followableTopicTestData,\n                    ),\n                    feedState = NewsFeedUiState.Loading,\n                    deepLinkedUserNewsResource = null,\n                    onTopicCheckedChanged = { _, _ -> },\n                    onTopicClick = {},\n                    saveFollowedTopics = {},\n                    onNewsResourcesCheckedChanged = { _, _ -> },\n                    onNewsResourceViewed = {},\n                    onDeepLinkOpened = {},\n                )\n            }\n        }\n\n        composeTestRule\n            .onNodeWithContentDescription(\n                composeTestRule.activity.resources.getString(R.string.feature_foryou_api_loading),\n            )\n            .assertExists()\n    }\n\n    @Test\n    fun feed_whenNoInterestsSelectionAndLoading_showsLoadingIndicator() {\n        composeTestRule.setContent {\n            Box {\n                ForYouScreen(\n                    isSyncing = false,\n                    onboardingUiState = OnboardingUiState.NotShown,\n                    feedState = NewsFeedUiState.Loading,\n                    deepLinkedUserNewsResource = null,\n                    onTopicCheckedChanged = { _, _ -> },\n                    onTopicClick = {},\n                    saveFollowedTopics = {},\n                    onNewsResourcesCheckedChanged = { _, _ -> },\n                    onNewsResourceViewed = {},\n                    onDeepLinkOpened = {},\n                )\n            }\n        }\n\n        composeTestRule\n            .onNodeWithContentDescription(\n                composeTestRule.activity.resources.getString(R.string.feature_foryou_api_loading),\n            )\n            .assertExists()\n    }\n\n    @Test\n    fun feed_whenNoInterestsSelectionAndLoaded_showsFeed() {\n        composeTestRule.setContent {\n            ForYouScreen(\n                isSyncing = false,\n                onboardingUiState = OnboardingUiState.NotShown,\n                feedState = NewsFeedUiState.Success(\n                    feed = userNewsResourcesTestData,\n                ),\n                deepLinkedUserNewsResource = null,\n                onTopicCheckedChanged = { _, _ -> },\n                onTopicClick = {},\n                saveFollowedTopics = {},\n                onNewsResourcesCheckedChanged = { _, _ -> },\n                onNewsResourceViewed = {},\n                onDeepLinkOpened = {},\n            )\n        }\n\n        composeTestRule\n            .onNodeWithText(\n                userNewsResourcesTestData[0].title,\n                substring = true,\n            )\n            .assertExists()\n            .assertHasClickAction()\n\n        composeTestRule.onNode(hasScrollToNodeAction())\n            .performScrollToNode(\n                hasText(\n                    userNewsResourcesTestData[1].title,\n                    substring = true,\n                ),\n            )\n\n        composeTestRule\n            .onNodeWithText(\n                userNewsResourcesTestData[1].title,\n                substring = true,\n            )\n            .assertExists()\n            .assertHasClickAction()\n    }\n}\n"
  },
  {
    "path": "feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreen.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.foryou.impl\n\nimport android.net.Uri\nimport android.os.Build.VERSION\nimport android.os.Build.VERSION_CODES\nimport androidx.activity.compose.ReportDrawnWhen\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.animation.fadeIn\nimport androidx.compose.animation.fadeOut\nimport androidx.compose.animation.slideInVertically\nimport androidx.compose.animation.slideOutVertically\nimport androidx.compose.foundation.gestures.Orientation\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.PaddingValues\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.WindowInsets\nimport androidx.compose.foundation.layout.fillMaxHeight\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.heightIn\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.safeDrawing\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.layout.systemBars\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.layout.widthIn\nimport androidx.compose.foundation.layout.windowInsetsBottomHeight\nimport androidx.compose.foundation.layout.windowInsetsPadding\nimport androidx.compose.foundation.lazy.LazyListScope\nimport androidx.compose.foundation.lazy.grid.GridCells\nimport androidx.compose.foundation.lazy.grid.LazyHorizontalGrid\nimport androidx.compose.foundation.lazy.grid.items\nimport androidx.compose.foundation.lazy.grid.rememberLazyGridState\nimport androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope\nimport androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid\nimport androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells\nimport androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan\nimport androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState\nimport androidx.compose.foundation.shape.CornerSize\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Surface\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.getValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.graphics.toArgb\nimport androidx.compose.ui.layout.layout\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalDensity\nimport androidx.compose.ui.platform.LocalInspectionMode\nimport androidx.compose.ui.platform.testTag\nimport androidx.compose.ui.res.painterResource\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.style.TextAlign\nimport androidx.compose.ui.tooling.preview.PreviewParameter\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.unit.max\nimport androidx.compose.ui.unit.sp\nimport androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel\nimport androidx.lifecycle.compose.collectAsStateWithLifecycle\nimport com.google.accompanist.permissions.ExperimentalPermissionsApi\nimport com.google.accompanist.permissions.PermissionStatus.Denied\nimport com.google.accompanist.permissions.rememberPermissionState\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.DynamicAsyncImage\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaButton\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaOverlayLoadingWheel\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.DecorativeScrollbar\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.DraggableScrollbar\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.rememberDraggableScroller\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.scrollbarState\nimport com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\nimport com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource\nimport com.google.samples.apps.nowinandroid.core.ui.DevicePreviews\nimport com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState\nimport com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent\nimport com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank\nimport com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider\nimport com.google.samples.apps.nowinandroid.core.ui.launchCustomChromeTab\nimport com.google.samples.apps.nowinandroid.core.ui.newsFeed\nimport com.google.samples.apps.nowinandroid.feature.foryou.api.R\n\n@Composable\nfun ForYouScreen(\n    onTopicClick: (String) -> Unit,\n    modifier: Modifier = Modifier,\n    viewModel: ForYouViewModel = hiltViewModel(),\n) {\n    val onboardingUiState by viewModel.onboardingUiState.collectAsStateWithLifecycle()\n    val feedState by viewModel.feedState.collectAsStateWithLifecycle()\n    val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle()\n    val deepLinkedUserNewsResource by viewModel.deepLinkedNewsResource.collectAsStateWithLifecycle()\n\n    ForYouScreen(\n        isSyncing = isSyncing,\n        onboardingUiState = onboardingUiState,\n        feedState = feedState,\n        deepLinkedUserNewsResource = deepLinkedUserNewsResource,\n        onTopicCheckedChanged = viewModel::updateTopicSelection,\n        onDeepLinkOpened = viewModel::onDeepLinkOpened,\n        onTopicClick = onTopicClick,\n        saveFollowedTopics = viewModel::dismissOnboarding,\n        onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved,\n        onNewsResourceViewed = { viewModel.setNewsResourceViewed(it, true) },\n        modifier = modifier,\n    )\n}\n\n@Composable\ninternal fun ForYouScreen(\n    isSyncing: Boolean,\n    onboardingUiState: OnboardingUiState,\n    feedState: NewsFeedUiState,\n    deepLinkedUserNewsResource: UserNewsResource?,\n    onTopicCheckedChanged: (String, Boolean) -> Unit,\n    onTopicClick: (String) -> Unit,\n    onDeepLinkOpened: (String) -> Unit,\n    saveFollowedTopics: () -> Unit,\n    onNewsResourcesCheckedChanged: (String, Boolean) -> Unit,\n    onNewsResourceViewed: (String) -> Unit,\n    modifier: Modifier = Modifier,\n) {\n    val isOnboardingLoading = onboardingUiState is OnboardingUiState.Loading\n    val isFeedLoading = feedState is NewsFeedUiState.Loading\n\n    // This code should be called when the UI is ready for use and relates to Time To Full Display.\n    ReportDrawnWhen { !isSyncing && !isOnboardingLoading && !isFeedLoading }\n\n    val itemsAvailable = feedItemsSize(feedState, onboardingUiState)\n\n    val state = rememberLazyStaggeredGridState()\n    val scrollbarState = state.scrollbarState(\n        itemsAvailable = itemsAvailable,\n    )\n    TrackScrollJank(scrollableState = state, stateName = \"forYou:feed\")\n\n    Box(\n        modifier = modifier\n            .fillMaxSize(),\n    ) {\n        LazyVerticalStaggeredGrid(\n            columns = StaggeredGridCells.Adaptive(300.dp),\n            contentPadding = PaddingValues(16.dp),\n            horizontalArrangement = Arrangement.spacedBy(16.dp),\n            verticalItemSpacing = 24.dp,\n            modifier = Modifier\n                .testTag(\"forYou:feed\"),\n            state = state,\n        ) {\n            onboarding(\n                onboardingUiState = onboardingUiState,\n                onTopicCheckedChanged = onTopicCheckedChanged,\n                saveFollowedTopics = saveFollowedTopics,\n                // Custom LayoutModifier to remove the enforced parent 16.dp contentPadding\n                // from the LazyVerticalGrid and enable edge-to-edge scrolling for this section\n                interestsItemModifier = Modifier.layout { measurable, constraints ->\n                    val placeable = measurable.measure(\n                        constraints.copy(\n                            maxWidth = constraints.maxWidth + 32.dp.roundToPx(),\n                        ),\n                    )\n                    layout(placeable.width, placeable.height) {\n                        placeable.place(0, 0)\n                    }\n                },\n            )\n\n            newsFeed(\n                feedState = feedState,\n                onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,\n                onNewsResourceViewed = onNewsResourceViewed,\n                onTopicClick = onTopicClick,\n            )\n\n            item(span = StaggeredGridItemSpan.FullLine, contentType = \"bottomSpacing\") {\n                Column {\n                    Spacer(modifier = Modifier.height(8.dp))\n                    // Add space for the content to clear the \"offline\" snackbar.\n                    // TODO: Check that the Scaffold handles this correctly in NiaApp\n                    // if (isOffline) Spacer(modifier = Modifier.height(48.dp))\n                    Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))\n                }\n            }\n        }\n        AnimatedVisibility(\n            visible = isSyncing || isFeedLoading || isOnboardingLoading,\n            enter = slideInVertically(\n                initialOffsetY = { fullHeight -> -fullHeight },\n            ) + fadeIn(),\n            exit = slideOutVertically(\n                targetOffsetY = { fullHeight -> -fullHeight },\n            ) + fadeOut(),\n        ) {\n            val loadingContentDescription = stringResource(id = R.string.feature_foryou_api_loading)\n            Box(\n                modifier = Modifier\n                    .fillMaxWidth()\n                    .padding(top = 8.dp),\n            ) {\n                NiaOverlayLoadingWheel(\n                    modifier = Modifier\n                        .align(Alignment.Center),\n                    contentDesc = loadingContentDescription,\n                )\n            }\n        }\n        state.DraggableScrollbar(\n            modifier = Modifier\n                .fillMaxHeight()\n                .windowInsetsPadding(WindowInsets.systemBars)\n                .padding(horizontal = 2.dp)\n                .align(Alignment.CenterEnd),\n            state = scrollbarState,\n            orientation = Orientation.Vertical,\n            onThumbMoved = state.rememberDraggableScroller(\n                itemsAvailable = itemsAvailable,\n            ),\n        )\n    }\n    TrackScreenViewEvent(screenName = \"ForYou\")\n    NotificationPermissionEffect()\n    DeepLinkEffect(\n        deepLinkedUserNewsResource,\n        onDeepLinkOpened,\n    )\n}\n\n/**\n * An extension on [LazyListScope] defining the onboarding portion of the for you screen.\n * Depending on the [onboardingUiState], this might emit no items.\n *\n */\nprivate fun LazyStaggeredGridScope.onboarding(\n    onboardingUiState: OnboardingUiState,\n    onTopicCheckedChanged: (String, Boolean) -> Unit,\n    saveFollowedTopics: () -> Unit,\n    interestsItemModifier: Modifier = Modifier,\n) {\n    when (onboardingUiState) {\n        OnboardingUiState.Loading,\n        OnboardingUiState.LoadFailed,\n        OnboardingUiState.NotShown,\n        -> Unit\n\n        is OnboardingUiState.Shown -> {\n            item(span = StaggeredGridItemSpan.FullLine, contentType = \"onboarding\") {\n                Column(modifier = interestsItemModifier) {\n                    Text(\n                        text = stringResource(R.string.feature_foryou_api_onboarding_guidance_title),\n                        textAlign = TextAlign.Center,\n                        modifier = Modifier\n                            .fillMaxWidth()\n                            .padding(top = 24.dp),\n                        style = MaterialTheme.typography.titleMedium,\n                    )\n                    Text(\n                        text = stringResource(R.string.feature_foryou_api_onboarding_guidance_subtitle),\n                        modifier = Modifier\n                            .fillMaxWidth()\n                            .padding(top = 8.dp, start = 24.dp, end = 24.dp),\n                        textAlign = TextAlign.Center,\n                        style = MaterialTheme.typography.bodyMedium,\n                    )\n                    TopicSelection(\n                        onboardingUiState,\n                        onTopicCheckedChanged,\n                        Modifier.padding(bottom = 8.dp),\n                    )\n                    // Done button\n                    Row(\n                        horizontalArrangement = Arrangement.Center,\n                        modifier = Modifier.fillMaxWidth(),\n                    ) {\n                        NiaButton(\n                            onClick = saveFollowedTopics,\n                            enabled = onboardingUiState.isDismissable,\n                            modifier = Modifier\n                                .padding(horizontal = 24.dp)\n                                .widthIn(364.dp)\n                                .fillMaxWidth(),\n                        ) {\n                            Text(\n                                text = stringResource(R.string.feature_foryou_api_done),\n                            )\n                        }\n                    }\n                }\n            }\n        }\n    }\n}\n\n@Composable\nprivate fun TopicSelection(\n    onboardingUiState: OnboardingUiState.Shown,\n    onTopicCheckedChanged: (String, Boolean) -> Unit,\n    modifier: Modifier = Modifier,\n) {\n    val lazyGridState = rememberLazyGridState()\n    val topicSelectionTestTag = \"forYou:topicSelection\"\n\n    TrackScrollJank(scrollableState = lazyGridState, stateName = topicSelectionTestTag)\n\n    Box(\n        modifier = modifier\n            .fillMaxWidth(),\n    ) {\n        LazyHorizontalGrid(\n            state = lazyGridState,\n            rows = GridCells.Fixed(3),\n            horizontalArrangement = Arrangement.spacedBy(12.dp),\n            verticalArrangement = Arrangement.spacedBy(12.dp),\n            contentPadding = PaddingValues(24.dp),\n            modifier = Modifier\n                // LazyHorizontalGrid has to be constrained in height.\n                // However, we can't set a fixed height because the horizontal grid contains\n                // vertical text that can be rescaled.\n                // When the fontScale is at most 1, we know that the horizontal grid will be at most\n                // 240dp tall, so this is an upper bound for when the font scale is at most 1.\n                // When the fontScale is greater than 1, the height required by the text inside the\n                // horizontal grid will increase by at most the same factor, so 240sp is a valid\n                // upper bound for how much space we need in that case.\n                // The maximum of these two bounds is therefore a valid upper bound in all cases.\n                .heightIn(max = max(240.dp, with(LocalDensity.current) { 240.sp.toDp() }))\n                .fillMaxWidth()\n                .testTag(topicSelectionTestTag),\n        ) {\n            items(\n                items = onboardingUiState.topics,\n                key = { it.topic.id },\n            ) {\n                SingleTopicButton(\n                    name = it.topic.name,\n                    topicId = it.topic.id,\n                    imageUrl = it.topic.imageUrl,\n                    isSelected = it.isFollowed,\n                    onClick = onTopicCheckedChanged,\n                )\n            }\n        }\n        lazyGridState.DecorativeScrollbar(\n            modifier = Modifier\n                .fillMaxWidth()\n                .padding(horizontal = 12.dp)\n                .align(Alignment.BottomStart),\n            state = lazyGridState.scrollbarState(itemsAvailable = onboardingUiState.topics.size),\n            orientation = Orientation.Horizontal,\n        )\n    }\n}\n\n@Composable\nprivate fun SingleTopicButton(\n    name: String,\n    topicId: String,\n    imageUrl: String,\n    isSelected: Boolean,\n    onClick: (String, Boolean) -> Unit,\n) {\n    Surface(\n        modifier = Modifier\n            .width(312.dp)\n            .heightIn(min = 56.dp),\n        shape = RoundedCornerShape(corner = CornerSize(8.dp)),\n        color = MaterialTheme.colorScheme.surface,\n        selected = isSelected,\n        onClick = {\n            onClick(topicId, !isSelected)\n        },\n    ) {\n        Row(\n            verticalAlignment = Alignment.CenterVertically,\n            modifier = Modifier.padding(start = 12.dp, end = 8.dp),\n        ) {\n            TopicIcon(\n                imageUrl = imageUrl,\n            )\n            Text(\n                text = name,\n                style = MaterialTheme.typography.titleSmall,\n                modifier = Modifier\n                    .padding(horizontal = 12.dp)\n                    .weight(1f),\n                color = MaterialTheme.colorScheme.onSurface,\n            )\n            NiaIconToggleButton(\n                checked = isSelected,\n                onCheckedChange = { checked -> onClick(topicId, checked) },\n                icon = {\n                    Icon(\n                        imageVector = NiaIcons.Add,\n                        contentDescription = name,\n                    )\n                },\n                checkedIcon = {\n                    Icon(\n                        imageVector = NiaIcons.Check,\n                        contentDescription = name,\n                    )\n                },\n            )\n        }\n    }\n}\n\n@Composable\nfun TopicIcon(\n    imageUrl: String,\n    modifier: Modifier = Modifier,\n) {\n    DynamicAsyncImage(\n        placeholder = painterResource(R.drawable.feature_foryou_api_ic_icon_placeholder),\n        imageUrl = imageUrl,\n        // decorative\n        contentDescription = null,\n        modifier = modifier\n            .padding(10.dp)\n            .size(32.dp),\n    )\n}\n\n@Composable\n@OptIn(ExperimentalPermissionsApi::class)\nprivate fun NotificationPermissionEffect() {\n    // Permission requests should only be made from an Activity Context, which is not present\n    // in previews\n    if (LocalInspectionMode.current) return\n    if (VERSION.SDK_INT < VERSION_CODES.TIRAMISU) return\n    val notificationsPermissionState = rememberPermissionState(\n        android.Manifest.permission.POST_NOTIFICATIONS,\n    )\n    LaunchedEffect(notificationsPermissionState) {\n        val status = notificationsPermissionState.status\n        if (status is Denied && !status.shouldShowRationale) {\n            notificationsPermissionState.launchPermissionRequest()\n        }\n    }\n}\n\n@Composable\nprivate fun DeepLinkEffect(\n    userNewsResource: UserNewsResource?,\n    onDeepLinkOpened: (String) -> Unit,\n) {\n    val context = LocalContext.current\n    val backgroundColor = MaterialTheme.colorScheme.background.toArgb()\n\n    LaunchedEffect(userNewsResource) {\n        if (userNewsResource == null) return@LaunchedEffect\n        if (!userNewsResource.hasBeenViewed) onDeepLinkOpened(userNewsResource.id)\n\n        launchCustomChromeTab(\n            context = context,\n            uri = Uri.parse(userNewsResource.url),\n            toolbarColor = backgroundColor,\n        )\n    }\n}\n\nprivate fun feedItemsSize(\n    feedState: NewsFeedUiState,\n    onboardingUiState: OnboardingUiState,\n): Int {\n    val feedSize = when (feedState) {\n        NewsFeedUiState.Loading -> 0\n        is NewsFeedUiState.Success -> feedState.feed.size\n    }\n    val onboardingSize = when (onboardingUiState) {\n        OnboardingUiState.Loading,\n        OnboardingUiState.LoadFailed,\n        OnboardingUiState.NotShown,\n        -> 0\n\n        is OnboardingUiState.Shown -> 1\n    }\n    return feedSize + onboardingSize\n}\n\n@DevicePreviews\n@Composable\nfun ForYouScreenPopulatedFeed(\n    @PreviewParameter(UserNewsResourcePreviewParameterProvider::class)\n    userNewsResources: List<UserNewsResource>,\n) {\n    NiaTheme {\n        ForYouScreen(\n            isSyncing = false,\n            onboardingUiState = OnboardingUiState.NotShown,\n            feedState = NewsFeedUiState.Success(\n                feed = userNewsResources,\n            ),\n            deepLinkedUserNewsResource = null,\n            onTopicCheckedChanged = { _, _ -> },\n            saveFollowedTopics = {},\n            onNewsResourcesCheckedChanged = { _, _ -> },\n            onNewsResourceViewed = {},\n            onTopicClick = {},\n            onDeepLinkOpened = {},\n        )\n    }\n}\n\n@DevicePreviews\n@Composable\nfun ForYouScreenOfflinePopulatedFeed(\n    @PreviewParameter(UserNewsResourcePreviewParameterProvider::class)\n    userNewsResources: List<UserNewsResource>,\n) {\n    NiaTheme {\n        ForYouScreen(\n            isSyncing = false,\n            onboardingUiState = OnboardingUiState.NotShown,\n            feedState = NewsFeedUiState.Success(\n                feed = userNewsResources,\n            ),\n            deepLinkedUserNewsResource = null,\n            onTopicCheckedChanged = { _, _ -> },\n            saveFollowedTopics = {},\n            onNewsResourcesCheckedChanged = { _, _ -> },\n            onNewsResourceViewed = {},\n            onTopicClick = {},\n            onDeepLinkOpened = {},\n        )\n    }\n}\n\n@DevicePreviews\n@Composable\nfun ForYouScreenTopicSelection(\n    @PreviewParameter(UserNewsResourcePreviewParameterProvider::class)\n    userNewsResources: List<UserNewsResource>,\n) {\n    NiaTheme {\n        ForYouScreen(\n            isSyncing = false,\n            onboardingUiState = OnboardingUiState.Shown(\n                topics = userNewsResources.flatMap { news -> news.followableTopics }\n                    .distinctBy { it.topic.id },\n            ),\n            feedState = NewsFeedUiState.Success(\n                feed = userNewsResources,\n            ),\n            deepLinkedUserNewsResource = null,\n            onTopicCheckedChanged = { _, _ -> },\n            saveFollowedTopics = {},\n            onNewsResourcesCheckedChanged = { _, _ -> },\n            onNewsResourceViewed = {},\n            onTopicClick = {},\n            onDeepLinkOpened = {},\n        )\n    }\n}\n\n@DevicePreviews\n@Composable\nfun ForYouScreenLoading() {\n    NiaTheme {\n        ForYouScreen(\n            isSyncing = false,\n            onboardingUiState = OnboardingUiState.Loading,\n            feedState = NewsFeedUiState.Loading,\n            deepLinkedUserNewsResource = null,\n            onTopicCheckedChanged = { _, _ -> },\n            saveFollowedTopics = {},\n            onNewsResourcesCheckedChanged = { _, _ -> },\n            onNewsResourceViewed = {},\n            onTopicClick = {},\n            onDeepLinkOpened = {},\n        )\n    }\n}\n\n@DevicePreviews\n@Composable\nfun ForYouScreenPopulatedAndLoading(\n    @PreviewParameter(UserNewsResourcePreviewParameterProvider::class)\n    userNewsResources: List<UserNewsResource>,\n) {\n    NiaTheme {\n        ForYouScreen(\n            isSyncing = true,\n            onboardingUiState = OnboardingUiState.Loading,\n            feedState = NewsFeedUiState.Success(\n                feed = userNewsResources,\n            ),\n            deepLinkedUserNewsResource = null,\n            onTopicCheckedChanged = { _, _ -> },\n            saveFollowedTopics = {},\n            onNewsResourcesCheckedChanged = { _, _ -> },\n            onNewsResourceViewed = {},\n            onTopicClick = {},\n            onDeepLinkOpened = {},\n        )\n    }\n}\n"
  },
  {
    "path": "feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouViewModel.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.foryou.impl\n\nimport androidx.lifecycle.SavedStateHandle\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent\nimport com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent.Param\nimport com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper\nimport com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery\nimport com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository\nimport com.google.samples.apps.nowinandroid.core.data.util.SyncManager\nimport com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase\nimport com.google.samples.apps.nowinandroid.core.notifications.DEEP_LINK_NEWS_RESOURCE_ID_KEY\nimport com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.SharingStarted\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.combine\nimport kotlinx.coroutines.flow.flatMapLatest\nimport kotlinx.coroutines.flow.flowOf\nimport kotlinx.coroutines.flow.map\nimport kotlinx.coroutines.flow.stateIn\nimport kotlinx.coroutines.launch\nimport javax.inject.Inject\n\n@HiltViewModel\nclass ForYouViewModel @Inject constructor(\n    private val savedStateHandle: SavedStateHandle,\n    syncManager: SyncManager,\n    private val analyticsHelper: AnalyticsHelper,\n    private val userDataRepository: UserDataRepository,\n    userNewsResourceRepository: UserNewsResourceRepository,\n    getFollowableTopics: GetFollowableTopicsUseCase,\n) : ViewModel() {\n\n    private val shouldShowOnboarding: Flow<Boolean> =\n        userDataRepository.userData.map { !it.shouldHideOnboarding }\n\n    val deepLinkedNewsResource = savedStateHandle.getStateFlow<String?>(\n        key = DEEP_LINK_NEWS_RESOURCE_ID_KEY,\n        null,\n    )\n        .flatMapLatest { newsResourceId ->\n            if (newsResourceId == null) {\n                flowOf(emptyList())\n            } else {\n                userNewsResourceRepository.observeAll(\n                    NewsResourceQuery(\n                        filterNewsIds = setOf(newsResourceId),\n                    ),\n                )\n            }\n        }\n        .map { it.firstOrNull() }\n        .stateIn(\n            scope = viewModelScope,\n            started = SharingStarted.WhileSubscribed(5_000),\n            initialValue = null,\n        )\n\n    val isSyncing = syncManager.isSyncing\n        .stateIn(\n            scope = viewModelScope,\n            started = SharingStarted.WhileSubscribed(5_000),\n            initialValue = false,\n        )\n\n    val feedState: StateFlow<NewsFeedUiState> =\n        userNewsResourceRepository.observeAllForFollowedTopics()\n            .map(NewsFeedUiState::Success)\n            .stateIn(\n                scope = viewModelScope,\n                started = SharingStarted.WhileSubscribed(5_000),\n                initialValue = NewsFeedUiState.Loading,\n            )\n\n    val onboardingUiState: StateFlow<OnboardingUiState> =\n        combine(\n            shouldShowOnboarding,\n            getFollowableTopics(),\n        ) { shouldShowOnboarding, topics ->\n            if (shouldShowOnboarding) {\n                OnboardingUiState.Shown(topics = topics)\n            } else {\n                OnboardingUiState.NotShown\n            }\n        }\n            .stateIn(\n                scope = viewModelScope,\n                started = SharingStarted.WhileSubscribed(5_000),\n                initialValue = OnboardingUiState.Loading,\n            )\n\n    fun updateTopicSelection(topicId: String, isChecked: Boolean) {\n        viewModelScope.launch {\n            userDataRepository.setTopicIdFollowed(topicId, isChecked)\n        }\n    }\n\n    fun updateNewsResourceSaved(newsResourceId: String, isChecked: Boolean) {\n        viewModelScope.launch {\n            userDataRepository.setNewsResourceBookmarked(newsResourceId, isChecked)\n        }\n    }\n\n    fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) {\n        viewModelScope.launch {\n            userDataRepository.setNewsResourceViewed(newsResourceId, viewed)\n        }\n    }\n\n    fun onDeepLinkOpened(newsResourceId: String) {\n        if (newsResourceId == deepLinkedNewsResource.value?.id) {\n            savedStateHandle[DEEP_LINK_NEWS_RESOURCE_ID_KEY] = null\n        }\n        analyticsHelper.logNewsDeepLinkOpen(newsResourceId = newsResourceId)\n        viewModelScope.launch {\n            userDataRepository.setNewsResourceViewed(\n                newsResourceId = newsResourceId,\n                viewed = true,\n            )\n        }\n    }\n\n    fun dismissOnboarding() {\n        viewModelScope.launch {\n            userDataRepository.setShouldHideOnboarding(true)\n        }\n    }\n}\n\nprivate fun AnalyticsHelper.logNewsDeepLinkOpen(newsResourceId: String) =\n    logEvent(\n        AnalyticsEvent(\n            type = \"news_deep_link_opened\",\n            extras = listOf(\n                Param(\n                    key = DEEP_LINK_NEWS_RESOURCE_ID_KEY,\n                    value = newsResourceId,\n                ),\n            ),\n        ),\n    )\n"
  },
  {
    "path": "feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/OnboardingUiState.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.foryou.impl\n\nimport com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic\n\n/**\n * A sealed hierarchy describing the onboarding state for the for you screen.\n */\nsealed interface OnboardingUiState {\n    /**\n     * The onboarding state is loading.\n     */\n    data object Loading : OnboardingUiState\n\n    /**\n     * The onboarding state was unable to load.\n     */\n    data object LoadFailed : OnboardingUiState\n\n    /**\n     * There is no onboarding state.\n     */\n    data object NotShown : OnboardingUiState\n\n    /**\n     * There is a onboarding state, with the given lists of topics.\n     */\n    data class Shown(\n        val topics: List<FollowableTopic>,\n    ) : OnboardingUiState {\n        /**\n         * True if the onboarding can be dismissed.\n         */\n        val isDismissable: Boolean get() = topics.any { it.isFollowed }\n    }\n}\n"
  },
  {
    "path": "feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/navigation/ForYouEntryProvider.kt",
    "content": "/*\n * Copyright 2025 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.foryou.impl.navigation\n\nimport androidx.navigation3.runtime.EntryProviderScope\nimport androidx.navigation3.runtime.NavKey\nimport com.google.samples.apps.nowinandroid.core.navigation.Navigator\nimport com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouNavKey\nimport com.google.samples.apps.nowinandroid.feature.foryou.impl.ForYouScreen\nimport com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic\n\nfun EntryProviderScope<NavKey>.forYouEntry(navigator: Navigator) {\n    entry<ForYouNavKey> {\n        ForYouScreen(\n            onTopicClick = navigator::navigateToTopic,\n        )\n    }\n}\n"
  },
  {
    "path": "feature/foryou/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreenScreenshotTests.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.foryou.impl\n\nimport androidx.activity.ComponentActivity\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.test.junit4.createAndroidComposeRule\nimport com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResultUtils\nimport com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResultUtils.matchesElements\nimport com.google.android.apps.common.testing.accessibility.framework.checks.TextContrastCheck\nimport com.google.android.apps.common.testing.accessibility.framework.matcher.ElementMatchers.withText\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\nimport com.google.samples.apps.nowinandroid.core.testing.util.DefaultTestDevices\nimport com.google.samples.apps.nowinandroid.core.testing.util.captureForDevice\nimport com.google.samples.apps.nowinandroid.core.testing.util.captureMultiDevice\nimport com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState\nimport com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success\nimport com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider\nimport com.google.samples.apps.nowinandroid.feature.foryou.impl.OnboardingUiState.NotShown\nimport com.google.samples.apps.nowinandroid.feature.foryou.impl.OnboardingUiState.Shown\nimport dagger.hilt.android.testing.HiltTestApplication\nimport org.hamcrest.Matchers\nimport org.junit.Before\nimport org.junit.Rule\nimport org.junit.Test\nimport org.junit.runner.RunWith\nimport org.robolectric.RobolectricTestRunner\nimport org.robolectric.annotation.Config\nimport org.robolectric.annotation.GraphicsMode\nimport org.robolectric.annotation.LooperMode\nimport java.util.TimeZone\n\n/**\n * Screenshot tests for the [ForYouScreen].\n */\n@RunWith(RobolectricTestRunner::class)\n@GraphicsMode(GraphicsMode.Mode.NATIVE)\n@Config(application = HiltTestApplication::class)\n@LooperMode(LooperMode.Mode.PAUSED)\nclass ForYouScreenScreenshotTests {\n\n    /**\n     * Use a test activity to set the content on.\n     */\n    @get:Rule\n    val composeTestRule = createAndroidComposeRule<ComponentActivity>()\n\n    private val userNewsResources = UserNewsResourcePreviewParameterProvider().values.first()\n\n    @Before\n    fun setTimeZone() {\n        // Make time zone deterministic in tests\n        TimeZone.setDefault(TimeZone.getTimeZone(\"UTC\"))\n    }\n\n    @Test\n    fun forYouScreenPopulatedFeed() {\n        composeTestRule.captureMultiDevice(\"ForYouScreenPopulatedFeed\") {\n            NiaTheme {\n                ForYouScreen(\n                    isSyncing = false,\n                    onboardingUiState = NotShown,\n                    feedState = Success(\n                        feed = userNewsResources,\n                    ),\n                    onTopicCheckedChanged = { _, _ -> },\n                    saveFollowedTopics = {},\n                    onNewsResourcesCheckedChanged = { _, _ -> },\n                    onNewsResourceViewed = {},\n                    onTopicClick = {},\n                    deepLinkedUserNewsResource = null,\n                    onDeepLinkOpened = {},\n                )\n            }\n        }\n    }\n\n    @Test\n    fun forYouScreenLoading() {\n        composeTestRule.captureMultiDevice(\"ForYouScreenLoading\") {\n            NiaTheme {\n                ForYouScreen(\n                    isSyncing = false,\n                    onboardingUiState = OnboardingUiState.Loading,\n                    feedState = NewsFeedUiState.Loading,\n                    onTopicCheckedChanged = { _, _ -> },\n                    saveFollowedTopics = {},\n                    onNewsResourcesCheckedChanged = { _, _ -> },\n                    onNewsResourceViewed = {},\n                    onTopicClick = {},\n                    deepLinkedUserNewsResource = null,\n                    onDeepLinkOpened = {},\n                )\n            }\n        }\n    }\n\n    @Test\n    fun forYouScreenTopicSelection() {\n        composeTestRule.captureMultiDevice(\n            \"ForYouScreenTopicSelection\",\n            accessibilitySuppressions = Matchers.allOf(\n                AccessibilityCheckResultUtils.matchesCheck(TextContrastCheck::class.java),\n                Matchers.anyOf(\n                    // Disabled Button\n                    matchesElements(withText(\"Done\")),\n\n                    // TODO investigate, seems a false positive\n                    matchesElements(withText(\"What are you interested in?\")),\n                    matchesElements(withText(\"UI\")),\n                ),\n            ),\n        ) {\n            ForYouScreenTopicSelection()\n        }\n    }\n\n    @Test\n    fun forYouScreenTopicSelection_dark() {\n        composeTestRule.captureForDevice(\n            deviceName = \"phone_dark\",\n            deviceSpec = DefaultTestDevices.PHONE.spec,\n            screenshotName = \"ForYouScreenTopicSelection\",\n            darkMode = true,\n        ) {\n            ForYouScreenTopicSelection()\n        }\n    }\n\n    @Test\n    fun forYouScreenPopulatedAndLoading() {\n        composeTestRule.captureMultiDevice(\"ForYouScreenPopulatedAndLoading\") {\n            ForYouScreenPopulatedAndLoading()\n        }\n    }\n\n    @Test\n    fun forYouScreenPopulatedAndLoading_dark() {\n        composeTestRule.captureForDevice(\n            deviceName = \"phone_dark\",\n            deviceSpec = DefaultTestDevices.PHONE.spec,\n            screenshotName = \"ForYouScreenPopulatedAndLoading\",\n            darkMode = true,\n        ) {\n            ForYouScreenPopulatedAndLoading()\n        }\n    }\n\n    @Composable\n    private fun ForYouScreenTopicSelection() {\n        NiaTheme {\n            NiaBackground {\n                ForYouScreen(\n                    isSyncing = false,\n                    onboardingUiState = Shown(\n                        topics = userNewsResources.flatMap { news -> news.followableTopics }\n                            .distinctBy { it.topic.id },\n                    ),\n                    feedState = Success(\n                        feed = userNewsResources,\n                    ),\n                    onTopicCheckedChanged = { _, _ -> },\n                    saveFollowedTopics = {},\n                    onNewsResourcesCheckedChanged = { _, _ -> },\n                    onNewsResourceViewed = {},\n                    onTopicClick = {},\n                    deepLinkedUserNewsResource = null,\n                    onDeepLinkOpened = {},\n                )\n            }\n        }\n    }\n\n    @Composable\n    private fun ForYouScreenPopulatedAndLoading() {\n        NiaTheme {\n            NiaBackground {\n                NiaTheme {\n                    ForYouScreen(\n                        isSyncing = true,\n                        onboardingUiState = OnboardingUiState.Loading,\n                        feedState = Success(\n                            feed = userNewsResources,\n                        ),\n                        onTopicCheckedChanged = { _, _ -> },\n                        saveFollowedTopics = {},\n                        onNewsResourcesCheckedChanged = { _, _ -> },\n                        onNewsResourceViewed = {},\n                        onTopicClick = {},\n                        deepLinkedUserNewsResource = null,\n                        onDeepLinkOpened = {},\n                    )\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "feature/foryou/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouViewModelTest.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.foryou.impl\n\nimport androidx.lifecycle.SavedStateHandle\nimport com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent\nimport com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent.Param\nimport com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository\nimport com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase\nimport com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic\nimport com.google.samples.apps.nowinandroid.core.model.data.NewsResource\nimport com.google.samples.apps.nowinandroid.core.model.data.Topic\nimport com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource\nimport com.google.samples.apps.nowinandroid.core.model.data.mapToUserNewsResources\nimport com.google.samples.apps.nowinandroid.core.notifications.DEEP_LINK_NEWS_RESOURCE_ID_KEY\nimport com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository\nimport com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository\nimport com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository\nimport com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData\nimport com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule\nimport com.google.samples.apps.nowinandroid.core.testing.util.TestAnalyticsHelper\nimport com.google.samples.apps.nowinandroid.core.testing.util.TestSyncManager\nimport com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState\nimport kotlinx.coroutines.flow.collect\nimport kotlinx.coroutines.flow.first\nimport kotlinx.coroutines.launch\nimport kotlinx.coroutines.test.UnconfinedTestDispatcher\nimport kotlinx.coroutines.test.advanceUntilIdle\nimport kotlinx.coroutines.test.runTest\nimport kotlinx.datetime.Instant\nimport org.junit.Before\nimport org.junit.Rule\nimport org.junit.Test\nimport kotlin.test.assertEquals\nimport kotlin.test.assertNull\nimport kotlin.test.assertTrue\n\n/**\n * To learn more about how this test handles Flows created with stateIn, see\n * https://developer.android.com/kotlin/flow/test#statein\n */\nclass ForYouViewModelTest {\n    @get:Rule\n    val mainDispatcherRule = MainDispatcherRule()\n\n    private val syncManager = TestSyncManager()\n    private val analyticsHelper = TestAnalyticsHelper()\n    private val userDataRepository = TestUserDataRepository()\n    private val topicsRepository = TestTopicsRepository()\n    private val newsRepository = TestNewsRepository()\n    private val userNewsResourceRepository = CompositeUserNewsResourceRepository(\n        newsRepository = newsRepository,\n        userDataRepository = userDataRepository,\n    )\n\n    private val getFollowableTopicsUseCase = GetFollowableTopicsUseCase(\n        topicsRepository = topicsRepository,\n        userDataRepository = userDataRepository,\n    )\n\n    private val savedStateHandle = SavedStateHandle()\n    private lateinit var viewModel: ForYouViewModel\n\n    @Before\n    fun setup() {\n        viewModel = ForYouViewModel(\n            syncManager = syncManager,\n            savedStateHandle = savedStateHandle,\n            analyticsHelper = analyticsHelper,\n            userDataRepository = userDataRepository,\n            userNewsResourceRepository = userNewsResourceRepository,\n            getFollowableTopics = getFollowableTopicsUseCase,\n        )\n    }\n\n    @Test\n    fun stateIsInitiallyLoading() = runTest {\n        assertEquals(\n            OnboardingUiState.Loading,\n            viewModel.onboardingUiState.value,\n        )\n        assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)\n    }\n\n    @Test\n    fun stateIsLoadingWhenFollowedTopicsAreLoading() = runTest {\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }\n\n        topicsRepository.sendTopics(sampleTopics)\n\n        assertEquals(\n            OnboardingUiState.Loading,\n            viewModel.onboardingUiState.value,\n        )\n        assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)\n    }\n\n    @Test\n    fun stateIsLoadingWhenAppIsSyncingWithNoInterests() = runTest {\n        syncManager.setSyncing(true)\n\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.isSyncing.collect() }\n\n        assertEquals(\n            true,\n            viewModel.isSyncing.value,\n        )\n    }\n\n    @Test\n    fun onboardingStateIsLoadingWhenTopicsAreLoading() = runTest {\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }\n\n        userDataRepository.setFollowedTopicIds(emptySet())\n\n        assertEquals(\n            OnboardingUiState.Loading,\n            viewModel.onboardingUiState.value,\n        )\n        assertEquals(NewsFeedUiState.Success(emptyList()), viewModel.feedState.value)\n    }\n\n    @Test\n    fun onboardingIsShownWhenNewsResourcesAreLoading() = runTest {\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }\n\n        topicsRepository.sendTopics(sampleTopics)\n        userDataRepository.setFollowedTopicIds(emptySet())\n\n        assertEquals(\n            OnboardingUiState.Shown(\n                topics = listOf(\n                    FollowableTopic(\n                        topic = Topic(\n                            id = \"0\",\n                            name = \"Headlines\",\n                            shortDescription = \"\",\n                            longDescription = \"long description\",\n                            url = \"URL\",\n                            imageUrl = \"image URL\",\n                        ),\n                        isFollowed = false,\n                    ),\n                    FollowableTopic(\n                        topic = Topic(\n                            id = \"1\",\n                            name = \"UI\",\n                            shortDescription = \"\",\n                            longDescription = \"long description\",\n                            url = \"URL\",\n                            imageUrl = \"image URL\",\n                        ),\n                        isFollowed = false,\n                    ),\n                    FollowableTopic(\n                        topic = Topic(\n                            id = \"2\",\n                            name = \"Tools\",\n                            shortDescription = \"\",\n                            longDescription = \"long description\",\n                            url = \"URL\",\n                            imageUrl = \"image URL\",\n                        ),\n                        isFollowed = false,\n                    ),\n                ),\n            ),\n            viewModel.onboardingUiState.value,\n        )\n        assertEquals(\n            NewsFeedUiState.Success(\n                feed = emptyList(),\n            ),\n            viewModel.feedState.value,\n        )\n    }\n\n    @Test\n    fun onboardingIsShownAfterLoadingEmptyFollowedTopics() = runTest {\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }\n\n        topicsRepository.sendTopics(sampleTopics)\n        userDataRepository.setFollowedTopicIds(emptySet())\n        newsRepository.sendNewsResources(sampleNewsResources)\n\n        assertEquals(\n            OnboardingUiState.Shown(\n                topics = listOf(\n                    FollowableTopic(\n                        topic = Topic(\n                            id = \"0\",\n                            name = \"Headlines\",\n                            shortDescription = \"\",\n                            longDescription = \"long description\",\n                            url = \"URL\",\n                            imageUrl = \"image URL\",\n                        ),\n                        isFollowed = false,\n                    ),\n                    FollowableTopic(\n                        topic = Topic(\n                            id = \"1\",\n                            name = \"UI\",\n                            shortDescription = \"\",\n                            longDescription = \"long description\",\n                            url = \"URL\",\n                            imageUrl = \"image URL\",\n                        ),\n                        isFollowed = false,\n                    ),\n                    FollowableTopic(\n                        topic = Topic(\n                            id = \"2\",\n                            name = \"Tools\",\n                            shortDescription = \"\",\n                            longDescription = \"long description\",\n                            url = \"URL\",\n                            imageUrl = \"image URL\",\n                        ),\n                        isFollowed = false,\n                    ),\n                ),\n            ),\n            viewModel.onboardingUiState.value,\n        )\n        assertEquals(\n            NewsFeedUiState.Success(\n                feed = emptyList(),\n            ),\n            viewModel.feedState.value,\n        )\n    }\n\n    @Test\n    fun onboardingIsNotShownAfterUserDismissesOnboarding() = runTest {\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }\n\n        topicsRepository.sendTopics(sampleTopics)\n\n        val followedTopicIds = setOf(\"0\", \"1\")\n        val userData = emptyUserData.copy(followedTopics = followedTopicIds)\n        userDataRepository.setUserData(userData)\n        viewModel.dismissOnboarding()\n\n        assertEquals(\n            OnboardingUiState.NotShown,\n            viewModel.onboardingUiState.value,\n        )\n        assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)\n\n        newsRepository.sendNewsResources(sampleNewsResources)\n\n        assertEquals(\n            OnboardingUiState.NotShown,\n            viewModel.onboardingUiState.value,\n        )\n        assertEquals(\n            NewsFeedUiState.Success(\n                feed = sampleNewsResources.mapToUserNewsResources(userData),\n            ),\n            viewModel.feedState.value,\n        )\n    }\n\n    @Test\n    fun topicSelectionUpdatesAfterSelectingTopic() = runTest {\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }\n\n        topicsRepository.sendTopics(sampleTopics)\n        userDataRepository.setFollowedTopicIds(emptySet())\n        newsRepository.sendNewsResources(sampleNewsResources)\n\n        assertEquals(\n            OnboardingUiState.Shown(\n                topics = sampleTopics.map {\n                    FollowableTopic(it, false)\n                },\n            ),\n            viewModel.onboardingUiState.value,\n        )\n        assertEquals(\n            NewsFeedUiState.Success(\n                feed = emptyList(),\n            ),\n            viewModel.feedState.value,\n        )\n\n        val followedTopicId = sampleTopics[1].id\n        viewModel.updateTopicSelection(followedTopicId, isChecked = true)\n\n        assertEquals(\n            OnboardingUiState.Shown(\n                topics = sampleTopics.map {\n                    FollowableTopic(it, it.id == followedTopicId)\n                },\n            ),\n            viewModel.onboardingUiState.value,\n        )\n\n        val userData = emptyUserData.copy(followedTopics = setOf(followedTopicId))\n\n        assertEquals(\n            NewsFeedUiState.Success(\n                feed = listOf(\n                    UserNewsResource(sampleNewsResources[1], userData),\n                    UserNewsResource(sampleNewsResources[2], userData),\n                ),\n            ),\n            viewModel.feedState.value,\n        )\n    }\n\n    @Test\n    fun topicSelectionUpdatesAfterUnselectingTopic() = runTest {\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }\n\n        topicsRepository.sendTopics(sampleTopics)\n        userDataRepository.setFollowedTopicIds(emptySet())\n        newsRepository.sendNewsResources(sampleNewsResources)\n        viewModel.updateTopicSelection(\"1\", isChecked = true)\n        viewModel.updateTopicSelection(\"1\", isChecked = false)\n\n        advanceUntilIdle()\n        assertEquals(\n            OnboardingUiState.Shown(\n                topics = listOf(\n                    FollowableTopic(\n                        topic = Topic(\n                            id = \"0\",\n                            name = \"Headlines\",\n                            shortDescription = \"\",\n                            longDescription = \"long description\",\n                            url = \"URL\",\n                            imageUrl = \"image URL\",\n                        ),\n                        isFollowed = false,\n                    ),\n                    FollowableTopic(\n                        topic = Topic(\n                            id = \"1\",\n                            name = \"UI\",\n                            shortDescription = \"\",\n                            longDescription = \"long description\",\n                            url = \"URL\",\n                            imageUrl = \"image URL\",\n                        ),\n                        isFollowed = false,\n                    ),\n                    FollowableTopic(\n                        topic = Topic(\n                            id = \"2\",\n                            name = \"Tools\",\n                            shortDescription = \"\",\n                            longDescription = \"long description\",\n                            url = \"URL\",\n                            imageUrl = \"image URL\",\n                        ),\n                        isFollowed = false,\n                    ),\n                ),\n            ),\n            viewModel.onboardingUiState.value,\n        )\n        assertEquals(\n            NewsFeedUiState.Success(\n                feed = emptyList(),\n            ),\n            viewModel.feedState.value,\n        )\n    }\n\n    @Test\n    fun newsResourceSelectionUpdatesAfterLoadingFollowedTopics() = runTest {\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }\n\n        val followedTopicIds = setOf(\"1\")\n        val userData = emptyUserData.copy(\n            followedTopics = followedTopicIds,\n            shouldHideOnboarding = true,\n        )\n\n        topicsRepository.sendTopics(sampleTopics)\n        userDataRepository.setUserData(userData)\n        newsRepository.sendNewsResources(sampleNewsResources)\n\n        val bookmarkedNewsResourceId = \"2\"\n        viewModel.updateNewsResourceSaved(\n            newsResourceId = bookmarkedNewsResourceId,\n            isChecked = true,\n        )\n\n        val userDataExpected = userData.copy(\n            bookmarkedNewsResources = setOf(bookmarkedNewsResourceId),\n        )\n\n        assertEquals(\n            OnboardingUiState.NotShown,\n            viewModel.onboardingUiState.value,\n        )\n        assertEquals(\n            NewsFeedUiState.Success(\n                feed = listOf(\n                    UserNewsResource(newsResource = sampleNewsResources[1], userDataExpected),\n                    UserNewsResource(newsResource = sampleNewsResources[2], userDataExpected),\n                ),\n            ),\n            viewModel.feedState.value,\n        )\n    }\n\n    @Test\n    fun deepLinkedNewsResourceIsFetchedAndResetAfterViewing() = runTest {\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.deepLinkedNewsResource.collect() }\n\n        newsRepository.sendNewsResources(sampleNewsResources)\n        userDataRepository.setUserData(emptyUserData)\n        savedStateHandle[DEEP_LINK_NEWS_RESOURCE_ID_KEY] = sampleNewsResources.first().id\n\n        assertEquals(\n            expected = UserNewsResource(\n                newsResource = sampleNewsResources.first(),\n                userData = emptyUserData,\n            ),\n            actual = viewModel.deepLinkedNewsResource.value,\n        )\n\n        viewModel.onDeepLinkOpened(\n            newsResourceId = sampleNewsResources.first().id,\n        )\n\n        assertNull(\n            viewModel.deepLinkedNewsResource.value,\n        )\n\n        assertTrue(\n            analyticsHelper.hasLogged(\n                AnalyticsEvent(\n                    type = \"news_deep_link_opened\",\n                    extras = listOf(\n                        Param(\n                            key = DEEP_LINK_NEWS_RESOURCE_ID_KEY,\n                            value = sampleNewsResources.first().id,\n                        ),\n                    ),\n                ),\n            ),\n        )\n    }\n\n    @Test\n    fun whenUpdateNewsResourceSavedIsCalled_bookmarkStateIsUpdated() = runTest {\n        val newsResourceId = \"123\"\n        viewModel.updateNewsResourceSaved(newsResourceId, true)\n\n        assertEquals(\n            expected = setOf(newsResourceId),\n            actual = userDataRepository.userData.first().bookmarkedNewsResources,\n        )\n\n        viewModel.updateNewsResourceSaved(newsResourceId, false)\n\n        assertEquals(\n            expected = emptySet(),\n            actual = userDataRepository.userData.first().bookmarkedNewsResources,\n        )\n    }\n}\n\nprivate val sampleTopics = listOf(\n    Topic(\n        id = \"0\",\n        name = \"Headlines\",\n        shortDescription = \"\",\n        longDescription = \"long description\",\n        url = \"URL\",\n        imageUrl = \"image URL\",\n    ),\n    Topic(\n        id = \"1\",\n        name = \"UI\",\n        shortDescription = \"\",\n        longDescription = \"long description\",\n        url = \"URL\",\n        imageUrl = \"image URL\",\n    ),\n    Topic(\n        id = \"2\",\n        name = \"Tools\",\n        shortDescription = \"\",\n        longDescription = \"long description\",\n        url = \"URL\",\n        imageUrl = \"image URL\",\n    ),\n)\n\nprivate val sampleNewsResources = listOf(\n    NewsResource(\n        id = \"1\",\n        title = \"Thanks for helping us reach 1M YouTube Subscribers\",\n        content = \"Thank you everyone for following the Now in Android series and everything the \" +\n            \"Android Developers YouTube channel has to offer. During the Android Developer \" +\n            \"Summit, our YouTube channel reached 1 million subscribers! Here’s a small video to \" +\n            \"thank you all.\",\n        url = \"https://youtu.be/-fJ6poHQrjM\",\n        headerImageUrl = \"https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg\",\n        publishDate = Instant.parse(\"2021-11-09T00:00:00.000Z\"),\n        type = \"Video 📺\",\n        topics = listOf(\n            Topic(\n                id = \"0\",\n                name = \"Headlines\",\n                shortDescription = \"\",\n                longDescription = \"long description\",\n                url = \"URL\",\n                imageUrl = \"image URL\",\n            ),\n        ),\n    ),\n    NewsResource(\n        id = \"2\",\n        title = \"Transformations and customisations in the Paging Library\",\n        content = \"A demonstration of different operations that can be performed with Paging. \" +\n            \"Transformations like inserting separators, when to create a new pager, and \" +\n            \"customisation options for consuming PagingData.\",\n        url = \"https://youtu.be/ZARz0pjm5YM\",\n        headerImageUrl = \"https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg\",\n        publishDate = Instant.parse(\"2021-11-01T00:00:00.000Z\"),\n        type = \"Video 📺\",\n        topics = listOf(\n            Topic(\n                id = \"1\",\n                name = \"UI\",\n                shortDescription = \"\",\n                longDescription = \"long description\",\n                url = \"URL\",\n                imageUrl = \"image URL\",\n            ),\n        ),\n    ),\n    NewsResource(\n        id = \"3\",\n        title = \"Community tip on Paging\",\n        content = \"Tips for using the Paging library from the developer community\",\n        url = \"https://youtu.be/r5JgIyS3t3s\",\n        headerImageUrl = \"https://i.ytimg.com/vi/r5JgIyS3t3s/maxresdefault.jpg\",\n        publishDate = Instant.parse(\"2021-11-08T00:00:00.000Z\"),\n        type = \"Video 📺\",\n        topics = listOf(\n            Topic(\n                id = \"1\",\n                name = \"UI\",\n                shortDescription = \"\",\n                longDescription = \"long description\",\n                url = \"URL\",\n                imageUrl = \"image URL\",\n            ),\n        ),\n    ),\n)\n"
  },
  {
    "path": "feature/interests/api/.gitignore",
    "content": "/build"
  },
  {
    "path": "feature/interests/api/README.md",
    "content": "# `:feature:interests:api`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :feature\n    direction TB\n    subgraph :feature:interests\n      direction TB\n      :feature:interests:api[api]:::android-library\n    end\n  end\n  subgraph :core\n    direction TB\n    :core:navigation[navigation]:::android-library\n  end\n\n  :feature:interests:api --> :core:navigation\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "feature/interests/api/build.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nplugins {\n    alias(libs.plugins.nowinandroid.android.feature.api)\n}\n\nandroid {\n    namespace = \"com.google.samples.apps.nowinandroid.feature.interests.api\"\n}\n"
  },
  {
    "path": "feature/interests/api/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<manifest />\n"
  },
  {
    "path": "feature/interests/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/api/navigation/InterestsNavKey.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.interests.api.navigation\n\nimport androidx.navigation3.runtime.NavKey\nimport kotlinx.serialization.Serializable\n\n@Serializable\ndata class InterestsNavKey(\n    // The ID of the topic which will be initially selected at this destination\n    val initialTopicId: String? = null,\n) : NavKey\n"
  },
  {
    "path": "feature/interests/api/src/main/res/drawable/feature_interests_api_ic_detail_placeholder.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2024 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"64dp\"\n    android:height=\"64dp\"\n    android:viewportWidth=\"64\"\n    android:viewportHeight=\"64\">\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M1,14.125C1,10.742 3.742,8 7.125,8H49.875C53.258,8 56,10.742 56,14.125V56.875C56,60.258 53.258,63 49.875,63H7.125C3.742,63 1,60.258 1,56.875V14.125Z\"\n        android:strokeColor=\"#8C4190\"\n        android:strokeWidth=\"2\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M31,18.79C31,17.437 31.307,16.536 31.768,15.988C32.203,15.469 32.895,15.125 34.008,15.125H47.367C48.119,15.125 48.846,15.306 49.377,15.775C49.883,16.223 50.375,17.08 50.375,18.79V27.272C50.375,28.871 50.054,29.737 49.63,30.208C49.229,30.654 48.56,30.938 47.367,30.938H34.008C32.719,30.938 32.046,30.497 31.65,29.94C31.211,29.322 31,28.399 31,27.272V18.79Z\"\n        android:strokeColor=\"#8C4190\"\n        android:strokeWidth=\"2\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M6,23a10,10 0,1 1,20 0a10,10 0,1 1,-20 0\"\n        android:strokeColor=\"#8C4190\"\n        android:strokeWidth=\"2\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M10,23l4,4l7.5,-7.5\"\n        android:strokeColor=\"#8C4190\"\n        android:strokeLineCap=\"round\"\n        android:strokeWidth=\"1.6\"/>\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M30,42H50 M30,52H50 M30,47H44 M30,37H42 M46,37H50 M30,57H35\"\n        android:strokeColor=\"#8C4190\"\n        android:strokeLineCap=\"round\"\n        android:strokeWidth=\"2\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M12,5C12,5 14,2 19,2C22.699,2 40.627,2 49.707,2C53.103,2 56.349,3.349 58.75,5.75V5.75C61.151,8.151 62.5,11.408 62.5,14.803V51\"\n        android:strokeColor=\"#8C4190\"\n        android:strokeLineCap=\"round\"\n        android:strokeWidth=\"2\" />\n</vector>\n"
  },
  {
    "path": "feature/interests/api/src/main/res/values/strings.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<resources>\n    <string name=\"feature_interests_api_title\">Interests</string>\n    <string name=\"feature_interests_api_loading\">Loading data</string>\n    <string name=\"feature_interests_api_empty_header\">\"No available data\"</string>\n    <string name=\"feature_interests_api_select_an_interest\">Select an Interest</string>\n</resources>\n"
  },
  {
    "path": "feature/interests/impl/.gitignore",
    "content": "/build"
  },
  {
    "path": "feature/interests/impl/README.md",
    "content": "# `:feature:interests:impl`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :feature\n    direction TB\n    subgraph :feature:interests\n      direction TB\n      :feature:interests:api[api]:::android-library\n      :feature:interests:impl[impl]:::android-library\n    end\n    subgraph :feature:topic\n      direction TB\n      :feature:topic:api[api]:::android-library\n    end\n  end\n  subgraph :core\n    direction TB\n    :core:analytics[analytics]:::android-library\n    :core:common[common]:::jvm-library\n    :core:data[data]:::android-library\n    :core:database[database]:::android-library\n    :core:datastore[datastore]:::android-library\n    :core:datastore-proto[datastore-proto]:::android-library\n    :core:designsystem[designsystem]:::android-library\n    :core:domain[domain]:::android-library\n    :core:model[model]:::jvm-library\n    :core:navigation[navigation]:::android-library\n    :core:network[network]:::android-library\n    :core:notifications[notifications]:::android-library\n    :core:ui[ui]:::android-library\n  end\n\n  :core:data -.-> :core:analytics\n  :core:data --> :core:common\n  :core:data --> :core:database\n  :core:data --> :core:datastore\n  :core:data --> :core:network\n  :core:data -.-> :core:notifications\n  :core:database --> :core:model\n  :core:datastore -.-> :core:common\n  :core:datastore --> :core:datastore-proto\n  :core:datastore --> :core:model\n  :core:domain --> :core:data\n  :core:domain --> :core:model\n  :core:network --> :core:common\n  :core:network --> :core:model\n  :core:notifications -.-> :core:common\n  :core:notifications --> :core:model\n  :core:ui --> :core:analytics\n  :core:ui --> :core:designsystem\n  :core:ui --> :core:model\n  :feature:interests:api --> :core:navigation\n  :feature:interests:impl -.-> :core:designsystem\n  :feature:interests:impl -.-> :core:domain\n  :feature:interests:impl -.-> :core:ui\n  :feature:interests:impl -.-> :feature:interests:api\n  :feature:interests:impl -.-> :feature:topic:api\n  :feature:topic:api -.-> :core:designsystem\n  :feature:topic:api --> :core:navigation\n  :feature:topic:api -.-> :core:ui\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "feature/interests/impl/build.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nplugins {\n    alias(libs.plugins.nowinandroid.android.feature.impl)\n    alias(libs.plugins.nowinandroid.android.library.compose)\n    alias(libs.plugins.nowinandroid.android.library.jacoco)\n}\nandroid {\n    namespace = \"com.google.samples.apps.nowinandroid.feature.interests.impl\"\n    testOptions.unitTests.isIncludeAndroidResources = true\n}\n\ndependencies {\n    implementation(projects.core.domain)\n    implementation(projects.feature.topic.api)\n    implementation(projects.feature.interests.api)\n    implementation(libs.androidx.compose.material3)\n    implementation(libs.androidx.compose.material3.adaptive)\n    implementation(libs.androidx.compose.material3.adaptive.layout)\n    implementation(libs.androidx.compose.material3.adaptive.navigation)\n    implementation(libs.androidx.compose.material3.adaptive.navigation3)\n\n    testImplementation(projects.core.testing)\n    testImplementation(projects.core.dataTest)\n    testImplementation(libs.robolectric)\n    testImplementation(libs.androidx.compose.ui.test)\n    testImplementation(libs.androidx.test.espresso.core)\n    testImplementation(libs.hilt.android.testing)\n    testImplementation(projects.uiTestHiltManifest)\n    testImplementation(projects.feature.topic.impl)\n    testImplementation(libs.androidx.navigation.testing)\n\n    androidTestImplementation(libs.bundles.androidx.compose.ui.test)\n    androidTestImplementation(projects.core.testing)\n}\n"
  },
  {
    "path": "feature/interests/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsScreenTest.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.interests.impl\n\nimport androidx.activity.ComponentActivity\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.test.assertCountEquals\nimport androidx.compose.ui.test.assertIsDisplayed\nimport androidx.compose.ui.test.junit4.createAndroidComposeRule\nimport androidx.compose.ui.test.onAllNodesWithContentDescription\nimport androidx.compose.ui.test.onNodeWithContentDescription\nimport androidx.compose.ui.test.onNodeWithText\nimport com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData\nimport org.junit.Before\nimport org.junit.Rule\nimport org.junit.Test\nimport com.google.samples.apps.nowinandroid.core.ui.R as CoreUiR\nimport com.google.samples.apps.nowinandroid.feature.interests.api.R as InterestsR\n\n/**\n * UI test for checking the correct behaviour of the Interests screen;\n * Verifies that, when a specific UiState is set, the corresponding\n * composables and details are shown\n */\nclass InterestsScreenTest {\n\n    @get:Rule\n    val composeTestRule = createAndroidComposeRule<ComponentActivity>()\n\n    private lateinit var interestsLoading: String\n    private lateinit var interestsEmptyHeader: String\n    private lateinit var interestsTopicCardFollowButton: String\n    private lateinit var interestsTopicCardUnfollowButton: String\n\n    @Before\n    fun setup() {\n        composeTestRule.activity.apply {\n            interestsLoading = getString(InterestsR.string.feature_interests_api_loading)\n            interestsEmptyHeader = getString(InterestsR.string.feature_interests_api_empty_header)\n            interestsTopicCardFollowButton =\n                getString(CoreUiR.string.core_ui_interests_card_follow_button_content_desc)\n            interestsTopicCardUnfollowButton =\n                getString(CoreUiR.string.core_ui_interests_card_unfollow_button_content_desc)\n        }\n    }\n\n    @Test\n    fun niaLoadingWheel_inTopics_whenScreenIsLoading_showLoading() {\n        composeTestRule.setContent {\n            InterestsScreen(uiState = InterestsUiState.Loading)\n        }\n\n        composeTestRule\n            .onNodeWithContentDescription(interestsLoading)\n            .assertExists()\n    }\n\n    @Test\n    fun interestsWithTopics_whenTopicsFollowed_showFollowedAndUnfollowedTopicsWithInfo() {\n        composeTestRule.setContent {\n            InterestsScreen(\n                uiState = InterestsUiState.Interests(\n                    topics = followableTopicTestData,\n                    selectedTopicId = null,\n                ),\n            )\n        }\n\n        composeTestRule\n            .onNodeWithText(followableTopicTestData[0].topic.name)\n            .assertIsDisplayed()\n        composeTestRule\n            .onNodeWithText(followableTopicTestData[1].topic.name)\n            .assertIsDisplayed()\n        composeTestRule\n            .onNodeWithText(followableTopicTestData[2].topic.name)\n            .assertIsDisplayed()\n\n        composeTestRule\n            .onAllNodesWithContentDescription(interestsTopicCardFollowButton)\n            .assertCountEquals(numberOfUnfollowedTopics)\n    }\n\n    @Test\n    fun topicsEmpty_whenDataIsEmptyOccurs_thenShowEmptyScreen() {\n        composeTestRule.setContent {\n            InterestsScreen(uiState = InterestsUiState.Empty)\n        }\n\n        composeTestRule\n            .onNodeWithText(interestsEmptyHeader)\n            .assertIsDisplayed()\n    }\n\n    @Composable\n    private fun InterestsScreen(uiState: InterestsUiState) {\n        InterestsScreen(\n            uiState = uiState,\n            followTopic = { _, _ -> },\n            onTopicClick = {},\n        )\n    }\n}\n\nprivate val numberOfUnfollowedTopics = followableTopicTestData.filter { !it.isFollowed }.size\n"
  },
  {
    "path": "feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsDetailPlaceholder.kt",
    "content": "/*\n * Copyright 2024 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.interests.impl\n\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material3.Card\nimport androidx.compose.material3.CardDefaults\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.res.painterResource\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.tooling.preview.Preview\nimport androidx.compose.ui.unit.dp\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\nimport com.google.samples.apps.nowinandroid.feature.interests.api.R\n\n@Composable\nfun InterestsDetailPlaceholder(modifier: Modifier = Modifier) {\n    Card(\n        modifier = modifier,\n        colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),\n        shape = RoundedCornerShape(24.dp, 24.dp, 0.dp, 0.dp),\n    ) {\n        Column(\n            modifier = Modifier.fillMaxSize(),\n            horizontalAlignment = Alignment.CenterHorizontally,\n            verticalArrangement = Arrangement.spacedBy(\n                20.dp,\n                alignment = Alignment.CenterVertically,\n            ),\n        ) {\n            Icon(\n                painter = painterResource(id = R.drawable.feature_interests_api_ic_detail_placeholder),\n                contentDescription = null,\n                tint = MaterialTheme.colorScheme.primary,\n            )\n            Text(\n                text = stringResource(id = R.string.feature_interests_api_select_an_interest),\n                style = MaterialTheme.typography.titleLarge,\n            )\n        }\n    }\n}\n\n@Preview(widthDp = 200, heightDp = 300)\n@Composable\nfun TopicDetailPlaceholderPreview() {\n    NiaTheme {\n        InterestsDetailPlaceholder()\n    }\n}\n"
  },
  {
    "path": "feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsScreen.kt",
    "content": "/*\n * Copyright 2021 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.interests.impl\n\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.getValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.tooling.preview.PreviewParameter\nimport androidx.lifecycle.compose.collectAsStateWithLifecycle\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\nimport com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic\nimport com.google.samples.apps.nowinandroid.core.ui.DevicePreviews\nimport com.google.samples.apps.nowinandroid.core.ui.FollowableTopicPreviewParameterProvider\nimport com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent\nimport com.google.samples.apps.nowinandroid.feature.interests.api.R\n\n@Composable\nfun InterestsScreen(\n    onTopicClick: (String) -> Unit,\n    modifier: Modifier = Modifier,\n    viewModel: InterestsViewModel,\n    shouldHighlightSelectedTopic: Boolean = false,\n) {\n    val uiState by viewModel.uiState.collectAsStateWithLifecycle()\n\n    InterestsScreen(\n        uiState = uiState,\n        followTopic = viewModel::followTopic,\n        onTopicClick = {\n            // TODO: this violates SSOT, events should go through the ViewModel\n            viewModel.onTopicClick(it)\n            onTopicClick(it)\n        },\n        shouldHighlightSelectedTopic = shouldHighlightSelectedTopic,\n        modifier = modifier,\n    )\n}\n\n@Composable\ninternal fun InterestsScreen(\n    uiState: InterestsUiState,\n    followTopic: (String, Boolean) -> Unit,\n    onTopicClick: (String) -> Unit,\n    modifier: Modifier = Modifier,\n    shouldHighlightSelectedTopic: Boolean = false,\n) {\n    Column(\n        modifier = modifier,\n        horizontalAlignment = Alignment.CenterHorizontally,\n    ) {\n        when (uiState) {\n            InterestsUiState.Loading ->\n                NiaLoadingWheel(\n                    contentDesc = stringResource(id = R.string.feature_interests_api_loading),\n                )\n\n            is InterestsUiState.Interests ->\n                TopicsTabContent(\n                    topics = uiState.topics,\n                    onTopicClick = onTopicClick,\n                    onFollowButtonClick = followTopic,\n                    selectedTopicId = uiState.selectedTopicId,\n                    shouldHighlightSelectedTopic = shouldHighlightSelectedTopic,\n                )\n\n            is InterestsUiState.Empty -> InterestsEmptyScreen()\n        }\n    }\n    TrackScreenViewEvent(screenName = \"Interests\")\n}\n\n@Composable\nprivate fun InterestsEmptyScreen() {\n    Text(text = stringResource(id = R.string.feature_interests_api_empty_header))\n}\n\n@DevicePreviews\n@Composable\nfun InterestsScreenPopulated(\n    @PreviewParameter(FollowableTopicPreviewParameterProvider::class)\n    followableTopics: List<FollowableTopic>,\n) {\n    NiaTheme {\n        NiaBackground {\n            InterestsScreen(\n                uiState = InterestsUiState.Interests(\n                    selectedTopicId = null,\n                    topics = followableTopics,\n                ),\n                followTopic = { _, _ -> },\n                onTopicClick = {},\n            )\n        }\n    }\n}\n\n@DevicePreviews\n@Composable\nfun InterestsScreenLoading() {\n    NiaTheme {\n        NiaBackground {\n            InterestsScreen(\n                uiState = InterestsUiState.Loading,\n                followTopic = { _, _ -> },\n                onTopicClick = {},\n            )\n        }\n    }\n}\n\n@DevicePreviews\n@Composable\nfun InterestsScreenEmpty() {\n    NiaTheme {\n        NiaBackground {\n            InterestsScreen(\n                uiState = InterestsUiState.Empty,\n                followTopic = { _, _ -> },\n                onTopicClick = {},\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsViewModel.kt",
    "content": "/*\n * Copyright 2021 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.interests.impl\n\nimport androidx.lifecycle.SavedStateHandle\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository\nimport com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase\nimport com.google.samples.apps.nowinandroid.core.domain.TopicSortField\nimport com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic\nimport com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsNavKey\nimport dagger.assisted.Assisted\nimport dagger.assisted.AssistedFactory\nimport dagger.assisted.AssistedInject\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport kotlinx.coroutines.flow.SharingStarted\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.combine\nimport kotlinx.coroutines.flow.stateIn\nimport kotlinx.coroutines.launch\n\n@HiltViewModel(assistedFactory = InterestsViewModel.Factory::class)\nclass InterestsViewModel @AssistedInject constructor(\n    private val savedStateHandle: SavedStateHandle,\n    val userDataRepository: UserDataRepository,\n    getFollowableTopics: GetFollowableTopicsUseCase,\n    // TODO: see comment below\n    @Assisted val key: InterestsNavKey,\n) : ViewModel() {\n\n    // TODO: this should no longer be necessary, the currently selected topic should be\n    //  available through the navigation state\n    // Key used to save and retrieve the currently selected topic id from saved state.\n    private val selectedTopicIdKey = \"selectedTopicIdKey\"\n\n    private val selectedTopicId = savedStateHandle.getStateFlow(\n        key = selectedTopicIdKey,\n        initialValue = key.initialTopicId,\n    )\n\n    val uiState: StateFlow<InterestsUiState> = combine(\n        selectedTopicId,\n        getFollowableTopics(sortBy = TopicSortField.NAME),\n        InterestsUiState::Interests,\n    ).stateIn(\n        scope = viewModelScope,\n        started = SharingStarted.WhileSubscribed(5_000),\n        initialValue = InterestsUiState.Loading,\n    )\n\n    fun followTopic(followedTopicId: String, followed: Boolean) {\n        viewModelScope.launch {\n            userDataRepository.setTopicIdFollowed(followedTopicId, followed)\n        }\n    }\n\n    fun onTopicClick(topicId: String?) {\n        // TODO: This should modify the navigation state directly rather than just updating the\n        //  savedStateHandle\n        savedStateHandle[selectedTopicIdKey] = topicId\n    }\n\n    @AssistedFactory\n    interface Factory {\n        fun create(key: InterestsNavKey): InterestsViewModel\n    }\n}\n\nsealed interface InterestsUiState {\n    data object Loading : InterestsUiState\n\n    data class Interests(\n        val selectedTopicId: String?,\n        val topics: List<FollowableTopic>,\n    ) : InterestsUiState\n\n    data object Empty : InterestsUiState\n}\n"
  },
  {
    "path": "feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/TabContent.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.interests.impl\n\nimport androidx.compose.foundation.gestures.Orientation\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.PaddingValues\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.WindowInsets\nimport androidx.compose.foundation.layout.fillMaxHeight\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.safeDrawing\nimport androidx.compose.foundation.layout.systemBars\nimport androidx.compose.foundation.layout.windowInsetsBottomHeight\nimport androidx.compose.foundation.layout.windowInsetsPadding\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.rememberLazyListState\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.platform.testTag\nimport androidx.compose.ui.unit.dp\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.DraggableScrollbar\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.rememberDraggableScroller\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.scrollbarState\nimport com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic\nimport com.google.samples.apps.nowinandroid.core.ui.InterestsItem\n\n@Composable\nfun TopicsTabContent(\n    topics: List<FollowableTopic>,\n    onTopicClick: (String) -> Unit,\n    onFollowButtonClick: (String, Boolean) -> Unit,\n    modifier: Modifier = Modifier,\n    withBottomSpacer: Boolean = true,\n    selectedTopicId: String? = null,\n    shouldHighlightSelectedTopic: Boolean = false,\n) {\n    Box(\n        modifier = modifier\n            .fillMaxWidth(),\n    ) {\n        val scrollableState = rememberLazyListState()\n        LazyColumn(\n            modifier = Modifier\n                .padding(horizontal = 24.dp)\n                .testTag(LIST_PANE_TEST_TAG),\n            contentPadding = PaddingValues(vertical = 16.dp),\n            state = scrollableState,\n        ) {\n            topics.forEach { followableTopic ->\n                val topicId = followableTopic.topic.id\n                item(key = topicId) {\n                    val isSelected = shouldHighlightSelectedTopic && topicId == selectedTopicId\n                    InterestsItem(\n                        name = followableTopic.topic.name,\n                        following = followableTopic.isFollowed,\n                        description = followableTopic.topic.shortDescription,\n                        topicImageUrl = followableTopic.topic.imageUrl,\n                        onClick = { onTopicClick(topicId) },\n                        onFollowButtonClick = { onFollowButtonClick(topicId, it) },\n                        isSelected = isSelected,\n                        modifier = Modifier.fillMaxWidth(),\n                    )\n                }\n            }\n\n            if (withBottomSpacer) {\n                item {\n                    Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))\n                }\n            }\n        }\n        val scrollbarState = scrollableState.scrollbarState(\n            itemsAvailable = topics.size,\n        )\n        scrollableState.DraggableScrollbar(\n            modifier = Modifier\n                .fillMaxHeight()\n                .windowInsetsPadding(WindowInsets.systemBars)\n                .padding(horizontal = 2.dp)\n                .align(Alignment.CenterEnd),\n            state = scrollbarState,\n            orientation = Orientation.Vertical,\n            onThumbMoved = scrollableState.rememberDraggableScroller(\n                itemsAvailable = topics.size,\n            ),\n        )\n    }\n}\n\nval LIST_PANE_TEST_TAG = \"interests:topics\"\n"
  },
  {
    "path": "feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/navigation/InterestsEntryProvider.kt",
    "content": "/*\n * Copyright 2025 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.interests.impl.navigation\n\nimport androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi\nimport androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy\nimport androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel\nimport androidx.navigation3.runtime.EntryProviderScope\nimport androidx.navigation3.runtime.NavKey\nimport com.google.samples.apps.nowinandroid.core.navigation.Navigator\nimport com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsNavKey\nimport com.google.samples.apps.nowinandroid.feature.interests.impl.InterestsDetailPlaceholder\nimport com.google.samples.apps.nowinandroid.feature.interests.impl.InterestsScreen\nimport com.google.samples.apps.nowinandroid.feature.interests.impl.InterestsViewModel\nimport com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic\n\n@OptIn(ExperimentalMaterial3AdaptiveApi::class)\nfun EntryProviderScope<NavKey>.interestsEntry(navigator: Navigator) {\n    entry<InterestsNavKey>(\n        metadata = ListDetailSceneStrategy.listPane {\n            InterestsDetailPlaceholder()\n        },\n    ) { key ->\n        val viewModel = hiltViewModel<InterestsViewModel, InterestsViewModel.Factory> {\n            it.create(key)\n        }\n        InterestsScreen(\n            // TODO: This event should either be provided by the ViewModel or by the navigator, not both\n            onTopicClick = navigator::navigateToTopic,\n\n            // TODO: This should be dynamically calculated based on the rendering scene\n            //  See https://github.com/android/nav3-recipes/commit/488f4811791ca3ed7192f4fe3c86e7371b32ebdc#diff-374e02026cdd2f68057dd940f203dc4ba7319930b33e9555c61af7e072211cabR89\n            shouldHighlightSelectedTopic = false,\n            viewModel = viewModel,\n        )\n    }\n}\n"
  },
  {
    "path": "feature/interests/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/impl/InterestsListDetailScreenTest.kt",
    "content": "/*\n * Copyright 2025 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n@file:OptIn(ExperimentalMaterial3AdaptiveApi::class)\n\npackage com.google.samples.apps.nowinandroid.interests.impl\n\nimport androidx.annotation.StringRes\nimport androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi\nimport androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.test.assertIsDisplayed\nimport androidx.compose.ui.test.assertIsNotDisplayed\nimport androidx.compose.ui.test.junit4.AndroidComposeTestRule\nimport androidx.compose.ui.test.junit4.createAndroidComposeRule\nimport androidx.compose.ui.test.onNodeWithTag\nimport androidx.compose.ui.test.onNodeWithText\nimport androidx.compose.ui.test.performClick\nimport androidx.navigation3.runtime.entryProvider\nimport androidx.navigation3.ui.NavDisplay\nimport androidx.test.espresso.Espresso\nimport com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\nimport com.google.samples.apps.nowinandroid.core.model.data.Topic\nimport com.google.samples.apps.nowinandroid.core.navigation.Navigator\nimport com.google.samples.apps.nowinandroid.core.navigation.rememberNavigationState\nimport com.google.samples.apps.nowinandroid.core.navigation.toEntries\nimport com.google.samples.apps.nowinandroid.feature.interests.api.R\nimport com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsNavKey\nimport com.google.samples.apps.nowinandroid.feature.interests.impl.LIST_PANE_TEST_TAG\nimport com.google.samples.apps.nowinandroid.feature.interests.impl.navigation.interestsEntry\nimport com.google.samples.apps.nowinandroid.feature.topic.impl.navigation.topicEntry\nimport com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity\nimport dagger.hilt.android.testing.HiltAndroidRule\nimport dagger.hilt.android.testing.HiltAndroidTest\nimport dagger.hilt.android.testing.HiltTestApplication\nimport kotlinx.coroutines.flow.first\nimport kotlinx.coroutines.runBlocking\nimport org.junit.Before\nimport org.junit.Rule\nimport org.junit.Test\nimport org.junit.runner.RunWith\nimport org.robolectric.RobolectricTestRunner\nimport org.robolectric.annotation.Config\nimport javax.inject.Inject\nimport kotlin.getValue\nimport kotlin.properties.ReadOnlyProperty\n\nprivate const val EXPANDED_WIDTH = \"w1200dp-h840dp\"\nprivate const val COMPACT_WIDTH = \"w412dp-h915dp\"\n\n@HiltAndroidTest\n@RunWith(RobolectricTestRunner::class)\n@Config(application = HiltTestApplication::class, sdk = [35])\nclass InterestsListDetailScreenTest {\n\n    @get:Rule(order = 0)\n    val hiltRule = HiltAndroidRule(this)\n\n    @get:Rule(order = 1)\n    val composeTestRule = createAndroidComposeRule<HiltComponentActivity>()\n\n    @Inject\n    lateinit var topicsRepository: TopicsRepository\n\n    /** Convenience function for getting all topics during tests, */\n    private fun getTopics(): List<Topic> = runBlocking {\n        topicsRepository.getTopics().first().sortedBy { it.name }\n    }\n\n    // The strings used for matching in these tests.\n    private val placeholderText by composeTestRule.stringResource(R.string.feature_interests_api_select_an_interest)\n\n    private val Topic.testTag\n        get() = \"topic:${this.id}\"\n\n    @Before\n    fun setup() {\n        hiltRule.inject()\n    }\n\n    @Test\n    @Config(qualifiers = EXPANDED_WIDTH)\n    fun expandedWidth_initialState_showsTwoPanesWithPlaceholder() {\n        composeTestRule.apply {\n            setContent {\n                NiaTheme {\n                    TestNavDisplay()\n                }\n            }\n            onNodeWithTag(LIST_PANE_TEST_TAG).assertIsDisplayed()\n            onNodeWithText(placeholderText).assertIsDisplayed()\n        }\n    }\n\n    @Composable\n    private fun TestNavDisplay() {\n        val startKey = InterestsNavKey(null)\n\n        val navigationState = rememberNavigationState(\n            startKey = startKey,\n            topLevelKeys = setOf(startKey),\n        )\n\n        val navigator = Navigator(navigationState)\n\n        val entryProvider = entryProvider {\n            interestsEntry(navigator)\n            topicEntry(navigator)\n        }\n\n        NavDisplay(\n            entries = navigationState.toEntries(entryProvider),\n            onBack = { navigator.goBack() },\n            sceneStrategy = rememberListDetailSceneStrategy(),\n        )\n    }\n\n    @Test\n    @Config(qualifiers = COMPACT_WIDTH)\n    fun compactWidth_initialState_showsListPane() {\n        composeTestRule.apply {\n            setContent {\n                NiaTheme {\n                    TestNavDisplay()\n                }\n            }\n\n            onNodeWithTag(LIST_PANE_TEST_TAG).assertIsDisplayed()\n            onNodeWithText(placeholderText).assertIsNotDisplayed()\n        }\n    }\n\n    @Test\n    @Config(qualifiers = EXPANDED_WIDTH)\n    fun expandedWidth_topicSelected_updatesDetailPane() {\n        composeTestRule.apply {\n            setContent {\n                NiaTheme {\n                    TestNavDisplay()\n                }\n            }\n            val firstTopic = getTopics().first()\n            onNodeWithText(firstTopic.name).performClick()\n            waitForIdle()\n\n            onNodeWithTag(LIST_PANE_TEST_TAG).assertIsDisplayed()\n            onNodeWithText(placeholderText).assertIsNotDisplayed()\n            onNodeWithTag(firstTopic.testTag).assertIsDisplayed()\n        }\n    }\n\n    @Test\n    @Config(qualifiers = COMPACT_WIDTH)\n    fun compactWidth_topicSelected_showsTopicDetailPane() {\n        composeTestRule.apply {\n            setContent {\n                NiaTheme {\n                    TestNavDisplay()\n                }\n            }\n\n            val firstTopic = getTopics().first()\n            onNodeWithText(firstTopic.name).performClick()\n\n            onNodeWithTag(LIST_PANE_TEST_TAG).assertIsNotDisplayed()\n            onNodeWithText(placeholderText).assertIsNotDisplayed()\n            onNodeWithTag(firstTopic.testTag).assertIsDisplayed()\n        }\n    }\n\n    @Test\n    @Config(qualifiers = COMPACT_WIDTH)\n    fun compactWidth_backPressFromTopicDetail_showsListPane() {\n        composeTestRule.apply {\n            setContent {\n                NiaTheme {\n                    TestNavDisplay()\n                }\n            }\n\n            val firstTopic = getTopics().first()\n            onNodeWithText(firstTopic.name).performClick()\n\n            waitForIdle()\n            Espresso.pressBack()\n\n            onNodeWithTag(LIST_PANE_TEST_TAG).assertIsDisplayed()\n            onNodeWithText(placeholderText).assertIsNotDisplayed()\n            onNodeWithTag(firstTopic.testTag).assertIsNotDisplayed()\n        }\n    }\n}\n\nprivate fun AndroidComposeTestRule<*, *>.stringResource(\n    @StringRes resId: Int,\n): ReadOnlyProperty<Any, String> =\n    ReadOnlyProperty { _, _ -> activity.getString(resId) }\n"
  },
  {
    "path": "feature/interests/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/impl/InterestsViewModelTest.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.interests.impl\n\nimport androidx.lifecycle.SavedStateHandle\nimport androidx.navigation.testing.invoke\nimport com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase\nimport com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic\nimport com.google.samples.apps.nowinandroid.core.model.data.Topic\nimport com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository\nimport com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository\nimport com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule\nimport com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsNavKey\nimport com.google.samples.apps.nowinandroid.feature.interests.impl.InterestsUiState\nimport com.google.samples.apps.nowinandroid.feature.interests.impl.InterestsViewModel\nimport kotlinx.coroutines.flow.collect\nimport kotlinx.coroutines.launch\nimport kotlinx.coroutines.test.UnconfinedTestDispatcher\nimport kotlinx.coroutines.test.runTest\nimport org.junit.Before\nimport org.junit.Rule\nimport org.junit.Test\nimport org.junit.runner.RunWith\nimport org.robolectric.RobolectricTestRunner\nimport org.robolectric.annotation.Config\nimport kotlin.test.assertEquals\n\n/**\n * To learn more about how this test handles Flows created with stateIn, see\n * https://developer.android.com/kotlin/flow/test#statein\n *\n * These tests use Robolectric because the subject under test (the ViewModel) uses\n * `SavedStateHandle.toRoute` which has a dependency on `android.os.Bundle`.\n *\n * TODO: Remove Robolectric if/when AndroidX Navigation API is updated to remove Android dependency.\n *  See https://issuetracker.google.com/340966212.\n */\n@RunWith(RobolectricTestRunner::class)\n@Config(sdk = [35])\nclass InterestsViewModelTest {\n\n    @get:Rule\n    val mainDispatcherRule = MainDispatcherRule()\n\n    private val userDataRepository = TestUserDataRepository()\n    private val topicsRepository = TestTopicsRepository()\n    private val getFollowableTopicsUseCase = GetFollowableTopicsUseCase(\n        topicsRepository = topicsRepository,\n        userDataRepository = userDataRepository,\n    )\n    private lateinit var viewModel: InterestsViewModel\n\n    @Before\n    fun setup() {\n        viewModel = InterestsViewModel(\n            savedStateHandle = SavedStateHandle(\n                route = InterestsNavKey(initialTopicId = testInputTopics[0].topic.id),\n            ),\n            userDataRepository = userDataRepository,\n            getFollowableTopics = getFollowableTopicsUseCase,\n            InterestsNavKey(initialTopicId = testInputTopics[0].topic.id),\n        )\n    }\n\n    @Test\n    fun uiState_whenInitialized_thenShowLoading() = runTest {\n        assertEquals(InterestsUiState.Loading, viewModel.uiState.value)\n    }\n\n    @Test\n    fun uiState_whenFollowedTopicsAreLoading_thenShowLoading() = runTest {\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }\n\n        userDataRepository.setFollowedTopicIds(emptySet())\n        assertEquals(InterestsUiState.Loading, viewModel.uiState.value)\n    }\n\n    @Test\n    fun uiState_whenFollowingNewTopic_thenShowUpdatedTopics() = runTest {\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }\n\n        val toggleTopicId = testOutputTopics[1].topic.id\n        topicsRepository.sendTopics(testInputTopics.map { it.topic })\n        userDataRepository.setFollowedTopicIds(setOf(testInputTopics[0].topic.id))\n\n        assertEquals(\n            false,\n            (viewModel.uiState.value as InterestsUiState.Interests)\n                .topics.first { it.topic.id == toggleTopicId }.isFollowed,\n        )\n\n        viewModel.followTopic(\n            followedTopicId = toggleTopicId,\n            true,\n        )\n\n        assertEquals(\n            InterestsUiState.Interests(\n                topics = testOutputTopics,\n                selectedTopicId = testInputTopics[0].topic.id,\n            ),\n            viewModel.uiState.value,\n        )\n    }\n\n    @Test\n    fun uiState_whenUnfollowingTopics_thenShowUpdatedTopics() = runTest {\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }\n\n        val toggleTopicId = testOutputTopics[1].topic.id\n\n        topicsRepository.sendTopics(testOutputTopics.map { it.topic })\n        userDataRepository.setFollowedTopicIds(\n            setOf(testOutputTopics[0].topic.id, testOutputTopics[1].topic.id),\n        )\n\n        assertEquals(\n            true,\n            (viewModel.uiState.value as InterestsUiState.Interests)\n                .topics.first { it.topic.id == toggleTopicId }.isFollowed,\n        )\n\n        viewModel.followTopic(\n            followedTopicId = toggleTopicId,\n            false,\n        )\n\n        assertEquals(\n            InterestsUiState.Interests(\n                topics = testInputTopics,\n                selectedTopicId = testInputTopics[0].topic.id,\n            ),\n            viewModel.uiState.value,\n        )\n    }\n}\n\nprivate const val TOPIC_1_NAME = \"Android Studio\"\nprivate const val TOPIC_2_NAME = \"Build\"\nprivate const val TOPIC_3_NAME = \"Compose\"\nprivate const val TOPIC_SHORT_DESC = \"At vero eos et accusamus.\"\nprivate const val TOPIC_LONG_DESC = \"At vero eos et accusamus et iusto odio dignissimos ducimus.\"\nprivate const val TOPIC_URL = \"URL\"\nprivate const val TOPIC_IMAGE_URL = \"Image URL\"\n\nprivate val testInputTopics = listOf(\n    FollowableTopic(\n        Topic(\n            id = \"0\",\n            name = TOPIC_1_NAME,\n            shortDescription = TOPIC_SHORT_DESC,\n            longDescription = TOPIC_LONG_DESC,\n            url = TOPIC_URL,\n            imageUrl = TOPIC_IMAGE_URL,\n        ),\n        isFollowed = true,\n    ),\n    FollowableTopic(\n        Topic(\n            id = \"1\",\n            name = TOPIC_2_NAME,\n            shortDescription = TOPIC_SHORT_DESC,\n            longDescription = TOPIC_LONG_DESC,\n            url = TOPIC_URL,\n            imageUrl = TOPIC_IMAGE_URL,\n        ),\n        isFollowed = false,\n    ),\n    FollowableTopic(\n        Topic(\n            id = \"2\",\n            name = TOPIC_3_NAME,\n            shortDescription = TOPIC_SHORT_DESC,\n            longDescription = TOPIC_LONG_DESC,\n            url = TOPIC_URL,\n            imageUrl = TOPIC_IMAGE_URL,\n        ),\n        isFollowed = false,\n    ),\n)\n\nprivate val testOutputTopics = listOf(\n    FollowableTopic(\n        Topic(\n            id = \"0\",\n            name = TOPIC_1_NAME,\n            shortDescription = TOPIC_SHORT_DESC,\n            longDescription = TOPIC_LONG_DESC,\n            url = TOPIC_URL,\n            imageUrl = TOPIC_IMAGE_URL,\n        ),\n        isFollowed = true,\n    ),\n    FollowableTopic(\n        Topic(\n            id = \"1\",\n            name = TOPIC_2_NAME,\n            shortDescription = TOPIC_SHORT_DESC,\n            longDescription = TOPIC_LONG_DESC,\n            url = TOPIC_URL,\n            imageUrl = TOPIC_IMAGE_URL,\n        ),\n        isFollowed = true,\n    ),\n    FollowableTopic(\n        Topic(\n            id = \"2\",\n            name = TOPIC_3_NAME,\n            shortDescription = TOPIC_SHORT_DESC,\n            longDescription = TOPIC_LONG_DESC,\n            url = TOPIC_URL,\n            imageUrl = TOPIC_IMAGE_URL,\n        ),\n        isFollowed = false,\n    ),\n)\n"
  },
  {
    "path": "feature/search/api/.gitignore",
    "content": "/build"
  },
  {
    "path": "feature/search/api/README.md",
    "content": "# `:feature:search:api`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :feature\n    direction TB\n    subgraph :feature:search\n      direction TB\n      :feature:search:api[api]:::android-library\n    end\n  end\n  subgraph :core\n    direction TB\n    :core:analytics[analytics]:::android-library\n    :core:common[common]:::jvm-library\n    :core:data[data]:::android-library\n    :core:database[database]:::android-library\n    :core:datastore[datastore]:::android-library\n    :core:datastore-proto[datastore-proto]:::jvm-library\n    :core:domain[domain]:::android-library\n    :core:model[model]:::jvm-library\n    :core:navigation[navigation]:::android-library\n    :core:network[network]:::android-library\n    :core:notifications[notifications]:::android-library\n  end\n\n  :core:data -.-> :core:analytics\n  :core:data --> :core:common\n  :core:data --> :core:database\n  :core:data --> :core:datastore\n  :core:data --> :core:network\n  :core:data -.-> :core:notifications\n  :core:database --> :core:model\n  :core:datastore -.-> :core:common\n  :core:datastore --> :core:datastore-proto\n  :core:datastore --> :core:model\n  :core:domain --> :core:data\n  :core:domain --> :core:model\n  :core:network --> :core:common\n  :core:network --> :core:model\n  :core:notifications -.-> :core:common\n  :core:notifications --> :core:model\n  :feature:search:api -.-> :core:domain\n  :feature:search:api --> :core:navigation\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "feature/search/api/build.gradle.kts",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nplugins {\n    alias(libs.plugins.nowinandroid.android.feature.api)\n}\n\nandroid {\n    namespace = \"com.google.samples.apps.nowinandroid.feature.search.api\"\n}\n\ndependencies {\n    implementation(projects.core.domain)\n}\n"
  },
  {
    "path": "feature/search/api/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2023 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<manifest />\n"
  },
  {
    "path": "feature/search/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/navigation/SearchNavKey.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.search.api.navigation\n\nimport androidx.navigation3.runtime.NavKey\nimport kotlinx.serialization.Serializable\n\n@Serializable\nobject SearchNavKey : NavKey\n"
  },
  {
    "path": "feature/search/api/src/main/res/values/strings.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2023 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<resources>\n    <string name=\"feature_search_api_title\">Search</string>\n    <string name=\"feature_search_api_clear_search_text_content_desc\">Clear search text</string>\n    <string name=\"feature_search_api_result_not_found\">Sorry, there is no content found for your search \\\"%1$s\\\"</string>\n    <string name=\"feature_search_api_not_ready\">Sorry, we are still processing the search index. Please come back later</string>\n    <string name=\"feature_search_api_try_another_search\">Try another search or explorer </string>\n    <string name=\"feature_search_api_interests\">Interests</string>\n    <string name=\"feature_search_api_to_browse_topics\"> to browse topics</string>\n    <string name=\"feature_search_api_topics\">Topics</string>\n    <string name=\"feature_search_api_updates\">Updates</string>\n    <string name=\"feature_search_api_recent_searches\">Recent searches</string>\n    <string name=\"feature_search_api_clear_recent_searches_content_desc\">Clear searches</string>\n</resources>\n"
  },
  {
    "path": "feature/search/impl/.gitignore",
    "content": "/build"
  },
  {
    "path": "feature/search/impl/README.md",
    "content": "# `:feature:search:impl`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :feature\n    direction TB\n    subgraph :feature:search\n      direction TB\n      :feature:search:api[api]:::android-library\n      :feature:search:impl[impl]:::android-library\n    end\n    subgraph :feature:interests\n      direction TB\n      :feature:interests:api[api]:::android-library\n    end\n    subgraph :feature:topic\n      direction TB\n      :feature:topic:api[api]:::android-library\n    end\n  end\n  subgraph :core\n    direction TB\n    :core:analytics[analytics]:::android-library\n    :core:common[common]:::jvm-library\n    :core:data[data]:::android-library\n    :core:database[database]:::android-library\n    :core:datastore[datastore]:::android-library\n    :core:datastore-proto[datastore-proto]:::jvm-library\n    :core:designsystem[designsystem]:::android-library\n    :core:domain[domain]:::android-library\n    :core:model[model]:::jvm-library\n    :core:navigation[navigation]:::android-library\n    :core:network[network]:::android-library\n    :core:notifications[notifications]:::android-library\n    :core:ui[ui]:::android-library\n  end\n\n  :core:data -.-> :core:analytics\n  :core:data --> :core:common\n  :core:data --> :core:database\n  :core:data --> :core:datastore\n  :core:data --> :core:network\n  :core:data -.-> :core:notifications\n  :core:database --> :core:model\n  :core:datastore -.-> :core:common\n  :core:datastore --> :core:datastore-proto\n  :core:datastore --> :core:model\n  :core:domain --> :core:data\n  :core:domain --> :core:model\n  :core:network --> :core:common\n  :core:network --> :core:model\n  :core:notifications -.-> :core:common\n  :core:notifications --> :core:model\n  :core:ui --> :core:analytics\n  :core:ui --> :core:designsystem\n  :core:ui --> :core:model\n  :feature:interests:api --> :core:navigation\n  :feature:search:api -.-> :core:domain\n  :feature:search:api --> :core:navigation\n  :feature:search:impl -.-> :core:designsystem\n  :feature:search:impl -.-> :core:domain\n  :feature:search:impl -.-> :core:ui\n  :feature:search:impl -.-> :feature:interests:api\n  :feature:search:impl -.-> :feature:search:api\n  :feature:search:impl -.-> :feature:topic:api\n  :feature:topic:api -.-> :core:designsystem\n  :feature:topic:api --> :core:navigation\n  :feature:topic:api -.-> :core:ui\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "feature/search/impl/build.gradle.kts",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nplugins {\n    alias(libs.plugins.nowinandroid.android.feature.impl)\n    alias(libs.plugins.nowinandroid.android.library.compose)\n    alias(libs.plugins.nowinandroid.android.library.jacoco)\n}\n\nandroid {\n    namespace = \"com.google.samples.apps.nowinandroid.feature.search.impl\"\n}\n\ndependencies {\n    implementation(projects.core.domain)\n    implementation(projects.feature.interests.api)\n    implementation(projects.feature.search.api)\n    implementation(projects.feature.topic.api)\n\n    testImplementation(projects.core.testing)\n\n    androidTestImplementation(libs.bundles.androidx.compose.ui.test)\n    androidTestImplementation(projects.core.testing)\n}\n"
  },
  {
    "path": "feature/search/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchScreenTest.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.search.impl\n\nimport androidx.activity.ComponentActivity\nimport androidx.compose.ui.test.assertCountEquals\nimport androidx.compose.ui.test.assertIsDisplayed\nimport androidx.compose.ui.test.assertIsFocused\nimport androidx.compose.ui.test.hasScrollToNodeAction\nimport androidx.compose.ui.test.junit4.createAndroidComposeRule\nimport androidx.compose.ui.test.onAllNodesWithContentDescription\nimport androidx.compose.ui.test.onFirst\nimport androidx.compose.ui.test.onNodeWithContentDescription\nimport androidx.compose.ui.test.onNodeWithTag\nimport androidx.compose.ui.test.onNodeWithText\nimport androidx.compose.ui.test.performScrollToIndex\nimport com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery\nimport com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.DARK\nimport com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.ANDROID\nimport com.google.samples.apps.nowinandroid.core.model.data.UserData\nimport com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource\nimport com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData\nimport com.google.samples.apps.nowinandroid.core.testing.data.newsResourcesTestData\nimport com.google.samples.apps.nowinandroid.core.ui.R.string\nimport com.google.samples.apps.nowinandroid.feature.search.api.R\nimport org.junit.Before\nimport org.junit.Rule\nimport org.junit.Test\n\n/**\n * UI test for checking the correct behaviour of the Search screen.\n */\nclass SearchScreenTest {\n\n    @get:Rule\n    val composeTestRule = createAndroidComposeRule<ComponentActivity>()\n\n    private lateinit var clearSearchContentDesc: String\n    private lateinit var followButtonContentDesc: String\n    private lateinit var unfollowButtonContentDesc: String\n    private lateinit var clearRecentSearchesContentDesc: String\n    private lateinit var topicsString: String\n    private lateinit var updatesString: String\n    private lateinit var tryAnotherSearchString: String\n    private lateinit var searchNotReadyString: String\n\n    private val userData: UserData = UserData(\n        bookmarkedNewsResources = setOf(\"1\", \"3\"),\n        viewedNewsResources = setOf(\"1\", \"2\", \"4\"),\n        followedTopics = emptySet(),\n        themeBrand = ANDROID,\n        darkThemeConfig = DARK,\n        shouldHideOnboarding = true,\n        useDynamicColor = false,\n    )\n\n    @Before\n    fun setup() {\n        composeTestRule.activity.apply {\n            clearSearchContentDesc = getString(R.string.feature_search_api_clear_search_text_content_desc)\n            clearRecentSearchesContentDesc = getString(R.string.feature_search_api_clear_recent_searches_content_desc)\n            followButtonContentDesc =\n                getString(string.core_ui_interests_card_follow_button_content_desc)\n            unfollowButtonContentDesc =\n                getString(string.core_ui_interests_card_unfollow_button_content_desc)\n            topicsString = getString(R.string.feature_search_api_topics)\n            updatesString = getString(R.string.feature_search_api_updates)\n            tryAnotherSearchString = getString(R.string.feature_search_api_try_another_search) +\n                \" \" + getString(R.string.feature_search_api_interests) + \" \" + getString(R.string.feature_search_api_to_browse_topics)\n            searchNotReadyString = getString(R.string.feature_search_api_not_ready)\n        }\n    }\n\n    @Test\n    fun searchTextField_isFocused() {\n        composeTestRule.setContent {\n            SearchScreen()\n        }\n\n        composeTestRule\n            .onNodeWithTag(\"searchTextField\")\n            .assertIsFocused()\n    }\n\n    @Test\n    fun emptySearchResult_emptyScreenIsDisplayed() {\n        composeTestRule.setContent {\n            SearchScreen(\n                searchResultUiState = SearchResultUiState.Success(),\n            )\n        }\n\n        composeTestRule\n            .onNodeWithText(tryAnotherSearchString)\n            .assertIsDisplayed()\n    }\n\n    @Test\n    fun emptySearchResult_nonEmptyRecentSearches_emptySearchScreenAndRecentSearchesAreDisplayed() {\n        val recentSearches = listOf(\"kotlin\")\n        composeTestRule.setContent {\n            SearchScreen(\n                searchResultUiState = SearchResultUiState.Success(),\n                recentSearchesUiState = RecentSearchQueriesUiState.Success(\n                    recentQueries = recentSearches.map(::RecentSearchQuery),\n                ),\n            )\n        }\n\n        composeTestRule\n            .onNodeWithText(tryAnotherSearchString)\n            .assertIsDisplayed()\n        composeTestRule\n            .onNodeWithContentDescription(clearRecentSearchesContentDesc)\n            .assertIsDisplayed()\n        composeTestRule\n            .onNodeWithText(\"kotlin\")\n            .assertIsDisplayed()\n    }\n\n    @Test\n    fun searchResultWithTopics_allTopicsAreVisible_followButtonsVisibleForTheNumOfFollowedTopics() {\n        composeTestRule.setContent {\n            SearchScreen(\n                searchResultUiState = SearchResultUiState.Success(topics = followableTopicTestData),\n            )\n        }\n\n        composeTestRule\n            .onNodeWithText(topicsString)\n            .assertIsDisplayed()\n\n        val scrollableNode = composeTestRule\n            .onAllNodes(hasScrollToNodeAction())\n            .onFirst()\n\n        followableTopicTestData.forEachIndexed { index, followableTopic ->\n            scrollableNode.performScrollToIndex(index)\n\n            composeTestRule\n                .onNodeWithText(followableTopic.topic.name)\n                .assertIsDisplayed()\n        }\n\n        composeTestRule\n            .onAllNodesWithContentDescription(followButtonContentDesc)\n            .assertCountEquals(2)\n        composeTestRule\n            .onAllNodesWithContentDescription(unfollowButtonContentDesc)\n            .assertCountEquals(1)\n    }\n\n    @Test\n    fun searchResultWithNewsResources_firstNewsResourcesIsVisible() {\n        composeTestRule.setContent {\n            SearchScreen(\n                searchResultUiState = SearchResultUiState.Success(\n                    newsResources = newsResourcesTestData.map {\n                        UserNewsResource(\n                            newsResource = it,\n                            userData = userData,\n                        )\n                    },\n                ),\n            )\n        }\n\n        composeTestRule\n            .onNodeWithText(updatesString)\n            .assertIsDisplayed()\n        composeTestRule\n            .onNodeWithText(newsResourcesTestData[0].title)\n            .assertIsDisplayed()\n    }\n\n    @Test\n    fun emptyQuery_notEmptyRecentSearches_verifyClearSearchesButton_displayed() {\n        val recentSearches = listOf(\"kotlin\", \"testing\")\n        composeTestRule.setContent {\n            SearchScreen(\n                searchResultUiState = SearchResultUiState.EmptyQuery,\n                recentSearchesUiState = RecentSearchQueriesUiState.Success(\n                    recentQueries = recentSearches.map(::RecentSearchQuery),\n                ),\n            )\n        }\n\n        composeTestRule\n            .onNodeWithContentDescription(clearRecentSearchesContentDesc)\n            .assertIsDisplayed()\n        composeTestRule\n            .onNodeWithText(\"kotlin\")\n            .assertIsDisplayed()\n        composeTestRule\n            .onNodeWithText(\"testing\")\n            .assertIsDisplayed()\n    }\n\n    @Test\n    fun searchNotReady_verifySearchNotReadyMessageIsVisible() {\n        composeTestRule.setContent {\n            SearchScreen(\n                searchResultUiState = SearchResultUiState.SearchNotReady,\n            )\n        }\n\n        composeTestRule\n            .onNodeWithText(searchNotReadyString)\n            .assertIsDisplayed()\n    }\n}\n"
  },
  {
    "path": "feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/RecentSearchQueriesUiState.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.search.impl\n\nimport com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery\n\nsealed interface RecentSearchQueriesUiState {\n    data object Loading : RecentSearchQueriesUiState\n\n    data class Success(\n        val recentQueries: List<RecentSearchQuery> = emptyList(),\n    ) : RecentSearchQueriesUiState\n}\n"
  },
  {
    "path": "feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchResultUiState.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.search.impl\n\nimport com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic\nimport com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource\n\nsealed interface SearchResultUiState {\n    data object Loading : SearchResultUiState\n\n    /**\n     * The state query is empty or too short. To distinguish the state between the\n     * (initial state or when the search query is cleared) vs the state where no search\n     * result is returned, explicitly define the empty query state.\n     */\n    data object EmptyQuery : SearchResultUiState\n\n    data object LoadFailed : SearchResultUiState\n\n    data class Success(\n        val topics: List<FollowableTopic> = emptyList(),\n        val newsResources: List<UserNewsResource> = emptyList(),\n    ) : SearchResultUiState {\n        fun isEmpty(): Boolean = topics.isEmpty() && newsResources.isEmpty()\n    }\n\n    /**\n     * A state where the search contents are not ready. This happens when the *Fts tables are not\n     * populated yet.\n     */\n    data object SearchNotReady : SearchResultUiState\n}\n"
  },
  {
    "path": "feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchScreen.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.search.impl\n\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.gestures.Orientation\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.PaddingValues\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.WindowInsets\nimport androidx.compose.foundation.layout.fillMaxHeight\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.safeDrawing\nimport androidx.compose.foundation.layout.systemBars\nimport androidx.compose.foundation.layout.windowInsetsBottomHeight\nimport androidx.compose.foundation.layout.windowInsetsPadding\nimport androidx.compose.foundation.layout.windowInsetsTopHeight\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid\nimport androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells\nimport androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan\nimport androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.foundation.text.KeyboardActions\nimport androidx.compose.foundation.text.KeyboardOptions\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TextField\nimport androidx.compose.material3.TextFieldDefaults\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.remember\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.focus.FocusRequester\nimport androidx.compose.ui.focus.focusRequester\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.input.key.Key\nimport androidx.compose.ui.input.key.key\nimport androidx.compose.ui.input.key.onKeyEvent\nimport androidx.compose.ui.platform.LocalSoftwareKeyboardController\nimport androidx.compose.ui.platform.testTag\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.AnnotatedString\nimport androidx.compose.ui.text.LinkAnnotation\nimport androidx.compose.ui.text.SpanStyle\nimport androidx.compose.ui.text.TextStyle\nimport androidx.compose.ui.text.buildAnnotatedString\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.text.input.ImeAction\nimport androidx.compose.ui.text.style.TextAlign\nimport androidx.compose.ui.text.style.TextDecoration\nimport androidx.compose.ui.text.withLink\nimport androidx.compose.ui.text.withStyle\nimport androidx.compose.ui.tooling.preview.Preview\nimport androidx.compose.ui.tooling.preview.PreviewParameter\nimport androidx.compose.ui.unit.dp\nimport androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel\nimport androidx.lifecycle.compose.collectAsStateWithLifecycle\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.DraggableScrollbar\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.rememberDraggableScroller\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.scrollbarState\nimport com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\nimport com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic\nimport com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource\nimport com.google.samples.apps.nowinandroid.core.ui.DevicePreviews\nimport com.google.samples.apps.nowinandroid.core.ui.InterestsItem\nimport com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success\nimport com.google.samples.apps.nowinandroid.core.ui.R.string\nimport com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent\nimport com.google.samples.apps.nowinandroid.core.ui.newsFeed\nimport com.google.samples.apps.nowinandroid.feature.search.api.R as searchR\n\n@Composable\ninternal fun SearchScreen(\n    onBackClick: () -> Unit,\n    onInterestsClick: () -> Unit,\n    onTopicClick: (String) -> Unit,\n    modifier: Modifier = Modifier,\n    searchViewModel: SearchViewModel = hiltViewModel(),\n) {\n    val recentSearchQueriesUiState by searchViewModel.recentSearchQueriesUiState.collectAsStateWithLifecycle()\n    val searchResultUiState by searchViewModel.searchResultUiState.collectAsStateWithLifecycle()\n    val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle()\n    SearchScreen(\n        modifier = modifier,\n        searchQuery = searchQuery,\n        recentSearchesUiState = recentSearchQueriesUiState,\n        searchResultUiState = searchResultUiState,\n        onSearchQueryChanged = searchViewModel::onSearchQueryChanged,\n        onSearchTriggered = searchViewModel::onSearchTriggered,\n        onClearRecentSearches = searchViewModel::clearRecentSearches,\n        onNewsResourcesCheckedChanged = searchViewModel::setNewsResourceBookmarked,\n        onNewsResourceViewed = { searchViewModel.setNewsResourceViewed(it, true) },\n        onFollowButtonClick = searchViewModel::followTopic,\n        onBackClick = onBackClick,\n        onInterestsClick = onInterestsClick,\n        onTopicClick = onTopicClick,\n    )\n}\n\n@Composable\ninternal fun SearchScreen(\n    modifier: Modifier = Modifier,\n    searchQuery: String = \"\",\n    recentSearchesUiState: RecentSearchQueriesUiState = RecentSearchQueriesUiState.Loading,\n    searchResultUiState: SearchResultUiState = SearchResultUiState.Loading,\n    onSearchQueryChanged: (String) -> Unit = {},\n    onSearchTriggered: (String) -> Unit = {},\n    onClearRecentSearches: () -> Unit = {},\n    onNewsResourcesCheckedChanged: (String, Boolean) -> Unit = { _, _ -> },\n    onNewsResourceViewed: (String) -> Unit = {},\n    onFollowButtonClick: (String, Boolean) -> Unit = { _, _ -> },\n    onBackClick: () -> Unit = {},\n    onInterestsClick: () -> Unit = {},\n    onTopicClick: (String) -> Unit = {},\n) {\n    TrackScreenViewEvent(screenName = \"Search\")\n    Column(modifier = modifier) {\n        Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing))\n        SearchToolbar(\n            onBackClick = onBackClick,\n            onSearchQueryChanged = onSearchQueryChanged,\n            onSearchTriggered = onSearchTriggered,\n            searchQuery = searchQuery,\n        )\n        when (searchResultUiState) {\n            SearchResultUiState.Loading,\n            SearchResultUiState.LoadFailed,\n            -> Unit\n\n            SearchResultUiState.SearchNotReady -> SearchNotReadyBody()\n            SearchResultUiState.EmptyQuery,\n            -> {\n                if (recentSearchesUiState is RecentSearchQueriesUiState.Success) {\n                    RecentSearchesBody(\n                        onClearRecentSearches = onClearRecentSearches,\n                        onRecentSearchClicked = {\n                            onSearchQueryChanged(it)\n                            onSearchTriggered(it)\n                        },\n                        recentSearchQueries = recentSearchesUiState.recentQueries.map { it.query },\n                    )\n                }\n            }\n\n            is SearchResultUiState.Success -> {\n                if (searchResultUiState.isEmpty()) {\n                    EmptySearchResultBody(\n                        searchQuery = searchQuery,\n                        onInterestsClick = onInterestsClick,\n                    )\n                    if (recentSearchesUiState is RecentSearchQueriesUiState.Success) {\n                        RecentSearchesBody(\n                            onClearRecentSearches = onClearRecentSearches,\n                            onRecentSearchClicked = {\n                                onSearchQueryChanged(it)\n                                onSearchTriggered(it)\n                            },\n                            recentSearchQueries = recentSearchesUiState.recentQueries.map { it.query },\n                        )\n                    }\n                } else {\n                    SearchResultBody(\n                        searchQuery = searchQuery,\n                        topics = searchResultUiState.topics,\n                        newsResources = searchResultUiState.newsResources,\n                        onSearchTriggered = onSearchTriggered,\n                        onTopicClick = onTopicClick,\n                        onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,\n                        onNewsResourceViewed = onNewsResourceViewed,\n                        onFollowButtonClick = onFollowButtonClick,\n                    )\n                }\n            }\n        }\n        Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))\n    }\n}\n\n@Composable\nfun EmptySearchResultBody(\n    searchQuery: String,\n    onInterestsClick: () -> Unit,\n) {\n    Column(\n        horizontalAlignment = Alignment.CenterHorizontally,\n        modifier = Modifier.padding(horizontal = 48.dp),\n    ) {\n        val message = stringResource(id = searchR.string.feature_search_api_result_not_found, searchQuery)\n        val start = message.indexOf(searchQuery)\n        Text(\n            text = AnnotatedString(\n                text = message,\n                spanStyles = listOf(\n                    AnnotatedString.Range(\n                        SpanStyle(fontWeight = FontWeight.Bold),\n                        start = start,\n                        end = start + searchQuery.length,\n                    ),\n                ),\n            ),\n            style = MaterialTheme.typography.bodyLarge,\n            textAlign = TextAlign.Center,\n            modifier = Modifier.padding(vertical = 24.dp),\n        )\n        val tryAnotherSearchString = buildAnnotatedString {\n            append(stringResource(id = searchR.string.feature_search_api_try_another_search))\n            append(\" \")\n            withLink(\n                LinkAnnotation.Clickable(\n                    tag = \"\",\n                    linkInteractionListener = {\n                        onInterestsClick()\n                    },\n                ),\n            ) {\n                withStyle(\n                    style = SpanStyle(\n                        textDecoration = TextDecoration.Underline,\n                        fontWeight = FontWeight.Bold,\n                    ),\n                ) {\n                    append(stringResource(id = searchR.string.feature_search_api_interests))\n                }\n            }\n\n            append(\" \")\n            append(stringResource(id = searchR.string.feature_search_api_to_browse_topics))\n        }\n        Text(\n            text = tryAnotherSearchString,\n            style = MaterialTheme.typography.bodyLarge.merge(\n                TextStyle(\n                    color = MaterialTheme.colorScheme.secondary,\n                    textAlign = TextAlign.Center,\n                ),\n            ),\n            modifier = Modifier\n                .padding(start = 36.dp, end = 36.dp, bottom = 24.dp),\n        )\n    }\n}\n\n@Composable\nprivate fun SearchNotReadyBody() {\n    Column(\n        horizontalAlignment = Alignment.CenterHorizontally,\n        modifier = Modifier.padding(horizontal = 48.dp),\n    ) {\n        Text(\n            text = stringResource(id = searchR.string.feature_search_api_not_ready),\n            style = MaterialTheme.typography.bodyLarge,\n            textAlign = TextAlign.Center,\n            modifier = Modifier.padding(vertical = 24.dp),\n        )\n    }\n}\n\n@Composable\nprivate fun SearchResultBody(\n    searchQuery: String,\n    topics: List<FollowableTopic>,\n    newsResources: List<UserNewsResource>,\n    onSearchTriggered: (String) -> Unit,\n    onTopicClick: (String) -> Unit,\n    onNewsResourcesCheckedChanged: (String, Boolean) -> Unit,\n    onNewsResourceViewed: (String) -> Unit,\n    onFollowButtonClick: (String, Boolean) -> Unit,\n) {\n    val state = rememberLazyStaggeredGridState()\n    Box(\n        modifier = Modifier\n            .fillMaxSize(),\n    ) {\n        LazyVerticalStaggeredGrid(\n            columns = StaggeredGridCells.Adaptive(300.dp),\n            contentPadding = PaddingValues(16.dp),\n            horizontalArrangement = Arrangement.spacedBy(16.dp),\n            verticalItemSpacing = 24.dp,\n            modifier = Modifier\n                .fillMaxSize()\n                .testTag(\"search:newsResources\"),\n            state = state,\n        ) {\n            if (topics.isNotEmpty()) {\n                item(\n                    span = StaggeredGridItemSpan.FullLine,\n                ) {\n                    Text(\n                        text = buildAnnotatedString {\n                            withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {\n                                append(stringResource(id = searchR.string.feature_search_api_topics))\n                            }\n                        },\n                        modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),\n                    )\n                }\n                topics.forEach { followableTopic ->\n                    val topicId = followableTopic.topic.id\n                    item(\n                        // Append a prefix to distinguish a key for news resources\n                        key = \"topic-$topicId\",\n                        span = StaggeredGridItemSpan.FullLine,\n                    ) {\n                        InterestsItem(\n                            name = followableTopic.topic.name,\n                            following = followableTopic.isFollowed,\n                            description = followableTopic.topic.shortDescription,\n                            topicImageUrl = followableTopic.topic.imageUrl,\n                            onClick = {\n                                // Pass the current search query to ViewModel to save it as recent searches\n                                onSearchTriggered(searchQuery)\n                                onTopicClick(topicId)\n                            },\n                            onFollowButtonClick = { onFollowButtonClick(topicId, it) },\n                        )\n                    }\n                }\n            }\n\n            if (newsResources.isNotEmpty()) {\n                item(\n                    span = StaggeredGridItemSpan.FullLine,\n                ) {\n                    Text(\n                        text = buildAnnotatedString {\n                            withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {\n                                append(stringResource(id = searchR.string.feature_search_api_updates))\n                            }\n                        },\n                        modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),\n                    )\n                }\n\n                newsFeed(\n                    feedState = Success(feed = newsResources),\n                    onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,\n                    onNewsResourceViewed = onNewsResourceViewed,\n                    onTopicClick = onTopicClick,\n                    onExpandedCardClick = {\n                        onSearchTriggered(searchQuery)\n                    },\n                )\n            }\n        }\n        val itemsAvailable = topics.size + newsResources.size\n        val scrollbarState = state.scrollbarState(\n            itemsAvailable = itemsAvailable,\n        )\n        state.DraggableScrollbar(\n            modifier = Modifier\n                .fillMaxHeight()\n                .windowInsetsPadding(WindowInsets.systemBars)\n                .padding(horizontal = 2.dp)\n                .align(Alignment.CenterEnd),\n            state = scrollbarState,\n            orientation = Orientation.Vertical,\n            onThumbMoved = state.rememberDraggableScroller(\n                itemsAvailable = itemsAvailable,\n            ),\n        )\n    }\n}\n\n@Composable\nprivate fun RecentSearchesBody(\n    recentSearchQueries: List<String>,\n    onClearRecentSearches: () -> Unit,\n    onRecentSearchClicked: (String) -> Unit,\n) {\n    Column {\n        Row(\n            horizontalArrangement = Arrangement.SpaceBetween,\n            verticalAlignment = Alignment.CenterVertically,\n            modifier = Modifier.fillMaxWidth(),\n        ) {\n            Text(\n                text = buildAnnotatedString {\n                    withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {\n                        append(stringResource(id = searchR.string.feature_search_api_recent_searches))\n                    }\n                },\n                modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),\n            )\n            if (recentSearchQueries.isNotEmpty()) {\n                IconButton(\n                    onClick = {\n                        onClearRecentSearches()\n                    },\n                    modifier = Modifier.padding(horizontal = 16.dp),\n                ) {\n                    Icon(\n                        imageVector = NiaIcons.Close,\n                        contentDescription = stringResource(\n                            id = searchR.string.feature_search_api_clear_recent_searches_content_desc,\n                        ),\n                        tint = MaterialTheme.colorScheme.onSurface,\n                    )\n                }\n            }\n        }\n        LazyColumn(modifier = Modifier.padding(horizontal = 16.dp)) {\n            items(recentSearchQueries) { recentSearch ->\n                Text(\n                    text = recentSearch,\n                    style = MaterialTheme.typography.headlineSmall,\n                    modifier = Modifier\n                        .padding(vertical = 16.dp)\n                        .clickable { onRecentSearchClicked(recentSearch) }\n                        .fillMaxWidth(),\n                )\n            }\n        }\n    }\n}\n\n@Composable\nprivate fun SearchToolbar(\n    searchQuery: String,\n    onSearchQueryChanged: (String) -> Unit,\n    onSearchTriggered: (String) -> Unit,\n    onBackClick: () -> Unit,\n    modifier: Modifier = Modifier,\n) {\n    Row(\n        verticalAlignment = Alignment.CenterVertically,\n        modifier = modifier.fillMaxWidth(),\n    ) {\n        IconButton(onClick = { onBackClick() }) {\n            Icon(\n                imageVector = NiaIcons.ArrowBack,\n                contentDescription = stringResource(\n                    id = string.core_ui_back,\n                ),\n            )\n        }\n        SearchTextField(\n            onSearchQueryChanged = onSearchQueryChanged,\n            onSearchTriggered = onSearchTriggered,\n            searchQuery = searchQuery,\n        )\n    }\n}\n\n@Composable\nprivate fun SearchTextField(\n    searchQuery: String,\n    onSearchQueryChanged: (String) -> Unit,\n    onSearchTriggered: (String) -> Unit,\n) {\n    val focusRequester = remember { FocusRequester() }\n    val keyboardController = LocalSoftwareKeyboardController.current\n\n    val onSearchExplicitlyTriggered = {\n        keyboardController?.hide()\n        onSearchTriggered(searchQuery)\n    }\n\n    TextField(\n        colors = TextFieldDefaults.colors(\n            focusedIndicatorColor = Color.Transparent,\n            unfocusedIndicatorColor = Color.Transparent,\n            disabledIndicatorColor = Color.Transparent,\n        ),\n        leadingIcon = {\n            Icon(\n                imageVector = NiaIcons.Search,\n                contentDescription = stringResource(\n                    id = searchR.string.feature_search_api_title,\n                ),\n                tint = MaterialTheme.colorScheme.onSurface,\n            )\n        },\n        trailingIcon = {\n            if (searchQuery.isNotEmpty()) {\n                IconButton(\n                    onClick = {\n                        onSearchQueryChanged(\"\")\n                    },\n                ) {\n                    Icon(\n                        imageVector = NiaIcons.Close,\n                        contentDescription = stringResource(\n                            id = searchR.string.feature_search_api_clear_search_text_content_desc,\n                        ),\n                        tint = MaterialTheme.colorScheme.onSurface,\n                    )\n                }\n            }\n        },\n        onValueChange = {\n            if (\"\\n\" !in it) onSearchQueryChanged(it)\n        },\n        modifier = Modifier\n            .fillMaxWidth()\n            .padding(16.dp)\n            .focusRequester(focusRequester)\n            .onKeyEvent {\n                if (it.key == Key.Enter) {\n                    if (searchQuery.isBlank()) return@onKeyEvent false\n                    onSearchExplicitlyTriggered()\n                    true\n                } else {\n                    false\n                }\n            }\n            .testTag(\"searchTextField\"),\n        shape = RoundedCornerShape(32.dp),\n        value = searchQuery,\n        keyboardOptions = KeyboardOptions(\n            imeAction = ImeAction.Search,\n        ),\n        keyboardActions = KeyboardActions(\n            onSearch = {\n                if (searchQuery.isBlank()) return@KeyboardActions\n                onSearchExplicitlyTriggered()\n            },\n        ),\n        maxLines = 1,\n        singleLine = true,\n    )\n    LaunchedEffect(Unit) {\n        focusRequester.requestFocus()\n    }\n}\n\n@Preview\n@Composable\nprivate fun SearchToolbarPreview() {\n    NiaTheme {\n        SearchToolbar(\n            searchQuery = \"\",\n            onBackClick = {},\n            onSearchQueryChanged = {},\n            onSearchTriggered = {},\n        )\n    }\n}\n\n@Preview\n@Composable\nprivate fun EmptySearchResultColumnPreview() {\n    NiaTheme {\n        EmptySearchResultBody(\n            onInterestsClick = {},\n            searchQuery = \"C++\",\n        )\n    }\n}\n\n@Preview\n@Composable\nprivate fun RecentSearchesBodyPreview() {\n    NiaTheme {\n        RecentSearchesBody(\n            onClearRecentSearches = {},\n            onRecentSearchClicked = {},\n            recentSearchQueries = listOf(\"kotlin\", \"jetpack compose\", \"testing\"),\n        )\n    }\n}\n\n@Preview\n@Composable\nprivate fun SearchNotReadyBodyPreview() {\n    NiaTheme {\n        SearchNotReadyBody()\n    }\n}\n\n@DevicePreviews\n@Composable\nprivate fun SearchScreenPreview(\n    @PreviewParameter(SearchUiStatePreviewParameterProvider::class)\n    searchResultUiState: SearchResultUiState,\n) {\n    NiaTheme {\n        SearchScreen(searchResultUiState = searchResultUiState)\n    }\n}\n"
  },
  {
    "path": "feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchUiStatePreviewParameterProvider.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n@file:Suppress(\"ktlint:standard:max-line-length\")\n\npackage com.google.samples.apps.nowinandroid.feature.search.impl\n\nimport androidx.compose.ui.tooling.preview.PreviewParameterProvider\nimport com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic\nimport com.google.samples.apps.nowinandroid.core.ui.PreviewParameterData.newsResources\nimport com.google.samples.apps.nowinandroid.core.ui.PreviewParameterData.topics\n\n/**\n * This [PreviewParameterProvider](https://developer.android.com/reference/kotlin/androidx/compose/ui/tooling/preview/PreviewParameterProvider)\n * provides list of [SearchResultUiState] for Composable previews.\n */\nclass SearchUiStatePreviewParameterProvider : PreviewParameterProvider<SearchResultUiState> {\n    override val values: Sequence<SearchResultUiState> = sequenceOf(\n        SearchResultUiState.Success(\n            topics = topics.mapIndexed { i, topic ->\n                FollowableTopic(topic = topic, isFollowed = i % 2 == 0)\n            },\n            newsResources = newsResources,\n        ),\n    )\n}\n"
  },
  {
    "path": "feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchViewModel.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.search.impl\n\nimport androidx.lifecycle.SavedStateHandle\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent\nimport com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent.Param\nimport com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper\nimport com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository\nimport com.google.samples.apps.nowinandroid.core.domain.GetRecentSearchQueriesUseCase\nimport com.google.samples.apps.nowinandroid.core.domain.GetSearchContentsUseCase\nimport com.google.samples.apps.nowinandroid.core.model.data.UserSearchResult\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport kotlinx.coroutines.flow.SharingStarted\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.catch\nimport kotlinx.coroutines.flow.flatMapLatest\nimport kotlinx.coroutines.flow.flowOf\nimport kotlinx.coroutines.flow.map\nimport kotlinx.coroutines.flow.stateIn\nimport kotlinx.coroutines.launch\nimport javax.inject.Inject\n\n@HiltViewModel\nclass SearchViewModel @Inject constructor(\n    getSearchContentsUseCase: GetSearchContentsUseCase,\n    recentSearchQueriesUseCase: GetRecentSearchQueriesUseCase,\n    private val searchContentsRepository: SearchContentsRepository,\n    private val recentSearchRepository: RecentSearchRepository,\n    private val userDataRepository: UserDataRepository,\n    private val savedStateHandle: SavedStateHandle,\n    private val analyticsHelper: AnalyticsHelper,\n) : ViewModel() {\n\n    val searchQuery = savedStateHandle.getStateFlow(key = SEARCH_QUERY, initialValue = \"\")\n\n    val searchResultUiState: StateFlow<SearchResultUiState> =\n        searchContentsRepository.getSearchContentsCount()\n            .flatMapLatest { totalCount ->\n                if (totalCount < SEARCH_MIN_FTS_ENTITY_COUNT) {\n                    flowOf(SearchResultUiState.SearchNotReady)\n                } else {\n                    searchQuery.flatMapLatest { query ->\n                        if (query.trim().length < SEARCH_QUERY_MIN_LENGTH) {\n                            flowOf(SearchResultUiState.EmptyQuery)\n                        } else {\n                            getSearchContentsUseCase(query)\n                                // Not using .asResult() here, because it emits Loading state every\n                                // time the user types a letter in the search box, which flickers the screen.\n                                .map<UserSearchResult, SearchResultUiState> { data ->\n                                    SearchResultUiState.Success(\n                                        topics = data.topics,\n                                        newsResources = data.newsResources,\n                                    )\n                                }\n                                .catch { emit(SearchResultUiState.LoadFailed) }\n                        }\n                    }\n                }\n            }.stateIn(\n                scope = viewModelScope,\n                started = SharingStarted.WhileSubscribed(5_000),\n                initialValue = SearchResultUiState.Loading,\n            )\n\n    val recentSearchQueriesUiState: StateFlow<RecentSearchQueriesUiState> =\n        recentSearchQueriesUseCase()\n            .map(RecentSearchQueriesUiState::Success)\n            .stateIn(\n                scope = viewModelScope,\n                started = SharingStarted.WhileSubscribed(5_000),\n                initialValue = RecentSearchQueriesUiState.Loading,\n            )\n\n    fun onSearchQueryChanged(query: String) {\n        savedStateHandle[SEARCH_QUERY] = query\n    }\n\n    /**\n     * Called when the search action is explicitly triggered by the user. For example, when the\n     * search icon is tapped in the IME or when the enter key is pressed in the search text field.\n     *\n     * The search results are displayed on the fly as the user types, but to explicitly save the\n     * search query in the search text field, defining this method.\n     */\n    fun onSearchTriggered(query: String) {\n        if (query.isBlank()) return\n        viewModelScope.launch {\n            recentSearchRepository.insertOrReplaceRecentSearch(searchQuery = query)\n        }\n        analyticsHelper.logEventSearchTriggered(query = query)\n    }\n\n    fun clearRecentSearches() {\n        viewModelScope.launch {\n            recentSearchRepository.clearRecentSearches()\n        }\n    }\n\n    fun setNewsResourceBookmarked(newsResourceId: String, isChecked: Boolean) {\n        viewModelScope.launch {\n            userDataRepository.setNewsResourceBookmarked(newsResourceId, isChecked)\n        }\n    }\n\n    fun followTopic(followedTopicId: String, followed: Boolean) {\n        viewModelScope.launch {\n            userDataRepository.setTopicIdFollowed(followedTopicId, followed)\n        }\n    }\n\n    fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) {\n        viewModelScope.launch {\n            userDataRepository.setNewsResourceViewed(newsResourceId, viewed)\n        }\n    }\n}\n\nprivate fun AnalyticsHelper.logEventSearchTriggered(query: String) =\n    logEvent(\n        event = AnalyticsEvent(\n            type = SEARCH_QUERY,\n            extras = listOf(element = Param(key = SEARCH_QUERY, value = query)),\n        ),\n    )\n\n/** Minimum length where search query is considered as [SearchResultUiState.EmptyQuery] */\nprivate const val SEARCH_QUERY_MIN_LENGTH = 2\n\n/** Minimum number of the fts table's entity count where it's considered as search is not ready */\nprivate const val SEARCH_MIN_FTS_ENTITY_COUNT = 1\nprivate const val SEARCH_QUERY = \"searchQuery\"\n"
  },
  {
    "path": "feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/navigation/SearchEntryProvider.kt",
    "content": "/*\n * Copyright 2025 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.search.impl.navigation\n\nimport androidx.navigation3.runtime.EntryProviderScope\nimport androidx.navigation3.runtime.NavKey\nimport com.google.samples.apps.nowinandroid.core.navigation.Navigator\nimport com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsNavKey\nimport com.google.samples.apps.nowinandroid.feature.search.api.navigation.SearchNavKey\nimport com.google.samples.apps.nowinandroid.feature.search.impl.SearchScreen\nimport com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic\n\nfun EntryProviderScope<NavKey>.searchEntry(navigator: Navigator) {\n    entry<SearchNavKey> {\n        SearchScreen(\n            onBackClick = { navigator.goBack() },\n            onInterestsClick = { navigator.navigate(InterestsNavKey()) },\n            onTopicClick = navigator::navigateToTopic,\n        )\n    }\n}\n"
  },
  {
    "path": "feature/search/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchViewModelTest.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.search.impl\n\nimport androidx.lifecycle.SavedStateHandle\nimport com.google.samples.apps.nowinandroid.core.analytics.NoOpAnalyticsHelper\nimport com.google.samples.apps.nowinandroid.core.domain.GetRecentSearchQueriesUseCase\nimport com.google.samples.apps.nowinandroid.core.domain.GetSearchContentsUseCase\nimport com.google.samples.apps.nowinandroid.core.testing.data.newsResourcesTestData\nimport com.google.samples.apps.nowinandroid.core.testing.data.topicsTestData\nimport com.google.samples.apps.nowinandroid.core.testing.repository.TestRecentSearchRepository\nimport com.google.samples.apps.nowinandroid.core.testing.repository.TestSearchContentsRepository\nimport com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository\nimport com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData\nimport com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule\nimport com.google.samples.apps.nowinandroid.feature.search.impl.RecentSearchQueriesUiState.Success\nimport com.google.samples.apps.nowinandroid.feature.search.impl.SearchResultUiState.EmptyQuery\nimport com.google.samples.apps.nowinandroid.feature.search.impl.SearchResultUiState.Loading\nimport com.google.samples.apps.nowinandroid.feature.search.impl.SearchResultUiState.SearchNotReady\nimport kotlinx.coroutines.flow.collect\nimport kotlinx.coroutines.flow.first\nimport kotlinx.coroutines.launch\nimport kotlinx.coroutines.test.UnconfinedTestDispatcher\nimport kotlinx.coroutines.test.runTest\nimport org.junit.Before\nimport org.junit.Rule\nimport org.junit.Test\nimport kotlin.test.assertEquals\nimport kotlin.test.assertIs\nimport kotlin.test.assertNull\n\n/**\n * To learn more about how this test handles Flows created with stateIn, see\n * https://developer.android.com/kotlin/flow/test#statein\n */\nclass SearchViewModelTest {\n\n    @get:Rule\n    val dispatcherRule = MainDispatcherRule()\n\n    private val userDataRepository = TestUserDataRepository()\n    private val searchContentsRepository = TestSearchContentsRepository()\n    private val getSearchContentsUseCase = GetSearchContentsUseCase(\n        searchContentsRepository = searchContentsRepository,\n        userDataRepository = userDataRepository,\n    )\n    private val recentSearchRepository = TestRecentSearchRepository()\n    private val getRecentQueryUseCase = GetRecentSearchQueriesUseCase(recentSearchRepository)\n\n    private lateinit var viewModel: SearchViewModel\n\n    @Before\n    fun setup() {\n        viewModel = SearchViewModel(\n            getSearchContentsUseCase = getSearchContentsUseCase,\n            recentSearchQueriesUseCase = getRecentQueryUseCase,\n            searchContentsRepository = searchContentsRepository,\n            savedStateHandle = SavedStateHandle(),\n            recentSearchRepository = recentSearchRepository,\n            userDataRepository = userDataRepository,\n            analyticsHelper = NoOpAnalyticsHelper(),\n        )\n        userDataRepository.setUserData(emptyUserData)\n    }\n\n    @Test\n    fun stateIsInitiallyLoading() = runTest {\n        assertEquals(Loading, viewModel.searchResultUiState.value)\n    }\n\n    @Test\n    fun stateIsEmptyQuery_withEmptySearchQuery() = runTest {\n        searchContentsRepository.addNewsResources(newsResourcesTestData)\n        searchContentsRepository.addTopics(topicsTestData)\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.searchResultUiState.collect() }\n\n        viewModel.onSearchQueryChanged(\"\")\n\n        assertEquals(EmptyQuery, viewModel.searchResultUiState.value)\n    }\n\n    @Test\n    fun emptyResultIsReturned_withNotMatchingQuery() = runTest {\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.searchResultUiState.collect() }\n\n        viewModel.onSearchQueryChanged(\"XXX\")\n        searchContentsRepository.addNewsResources(newsResourcesTestData)\n        searchContentsRepository.addTopics(topicsTestData)\n\n        val result = viewModel.searchResultUiState.value\n        assertIs<SearchResultUiState.Success>(result)\n    }\n\n    @Test\n    fun recentSearches_verifyUiStateIsSuccess() = runTest {\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.recentSearchQueriesUiState.collect() }\n        viewModel.onSearchTriggered(\"kotlin\")\n\n        val result = viewModel.recentSearchQueriesUiState.value\n        assertIs<Success>(result)\n    }\n\n    @Test\n    fun searchNotReady_withNoFtsTableEntity() = runTest {\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.searchResultUiState.collect() }\n\n        viewModel.onSearchQueryChanged(\"\")\n\n        assertEquals(SearchNotReady, viewModel.searchResultUiState.value)\n    }\n\n    @Test\n    fun emptySearchText_isNotAddedToRecentSearches() = runTest {\n        viewModel.onSearchTriggered(\"\")\n\n        val recentSearchQueriesStream = getRecentQueryUseCase()\n        val recentSearchQueries = recentSearchQueriesStream.first()\n        val recentSearchQuery = recentSearchQueries.firstOrNull()\n\n        assertNull(recentSearchQuery)\n    }\n\n    @Test\n    fun searchTextWithThreeSpaces_isEmptyQuery() = runTest {\n        searchContentsRepository.addNewsResources(newsResourcesTestData)\n        searchContentsRepository.addTopics(topicsTestData)\n        val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.searchResultUiState.collect() }\n\n        viewModel.onSearchQueryChanged(\"   \")\n\n        assertIs<EmptyQuery>(viewModel.searchResultUiState.value)\n\n        collectJob.cancel()\n    }\n\n    @Test\n    fun searchTextWithThreeSpacesAndOneLetter_isEmptyQuery() = runTest {\n        searchContentsRepository.addNewsResources(newsResourcesTestData)\n        searchContentsRepository.addTopics(topicsTestData)\n        val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.searchResultUiState.collect() }\n\n        viewModel.onSearchQueryChanged(\"   a\")\n\n        assertIs<EmptyQuery>(viewModel.searchResultUiState.value)\n\n        collectJob.cancel()\n    }\n\n    @Test\n    fun whenToggleNewsResourceSavedIsCalled_bookmarkStateIsUpdated() = runTest {\n        val newsResourceId = \"123\"\n        viewModel.setNewsResourceBookmarked(newsResourceId, true)\n\n        assertEquals(\n            expected = setOf(newsResourceId),\n            actual = userDataRepository.userData.first().bookmarkedNewsResources,\n        )\n\n        viewModel.setNewsResourceBookmarked(newsResourceId, false)\n\n        assertEquals(\n            expected = emptySet(),\n            actual = userDataRepository.userData.first().bookmarkedNewsResources,\n        )\n    }\n}\n"
  },
  {
    "path": "feature/settings/impl/.gitignore",
    "content": "/build"
  },
  {
    "path": "feature/settings/impl/README.md",
    "content": "# `:feature:settings:api`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :feature\n    direction TB\n    subgraph :feature:settings\n      direction TB\n      :feature:settings:impl[impl]:::android-library\n    end\n  end\n  subgraph :core\n    direction TB\n    :core:analytics[analytics]:::android-library\n    :core:common[common]:::jvm-library\n    :core:data[data]:::android-library\n    :core:database[database]:::android-library\n    :core:datastore[datastore]:::android-library\n    :core:datastore-proto[datastore-proto]:::jvm-library\n    :core:designsystem[designsystem]:::android-library\n    :core:model[model]:::jvm-library\n    :core:network[network]:::android-library\n    :core:notifications[notifications]:::android-library\n    :core:ui[ui]:::android-library\n  end\n\n  :core:data -.-> :core:analytics\n  :core:data --> :core:common\n  :core:data --> :core:database\n  :core:data --> :core:datastore\n  :core:data --> :core:network\n  :core:data -.-> :core:notifications\n  :core:database --> :core:model\n  :core:datastore -.-> :core:common\n  :core:datastore --> :core:datastore-proto\n  :core:datastore --> :core:model\n  :core:network --> :core:common\n  :core:network --> :core:model\n  :core:notifications -.-> :core:common\n  :core:notifications --> :core:model\n  :core:ui --> :core:analytics\n  :core:ui --> :core:designsystem\n  :core:ui --> :core:model\n  :feature:settings:impl -.-> :core:data\n  :feature:settings:impl -.-> :core:designsystem\n  :feature:settings:impl -.-> :core:ui\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "feature/settings/impl/build.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nplugins {\n    alias(libs.plugins.nowinandroid.android.feature.impl)\n    alias(libs.plugins.nowinandroid.android.library.compose)\n    alias(libs.plugins.nowinandroid.android.library.jacoco)\n}\n\nandroid {\n    namespace = \"com.google.samples.apps.nowinandroid.feature.settings.impl\"\n}\n\ndependencies {\n    implementation(libs.androidx.appcompat)\n    implementation(libs.google.oss.licenses)\n    implementation(projects.core.data)\n\n    testImplementation(projects.core.testing)\n\n    androidTestImplementation(libs.bundles.androidx.compose.ui.test)\n}\n"
  },
  {
    "path": "feature/settings/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/settings/impl/SettingsDialogTest.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.settings.impl\n\nimport androidx.activity.ComponentActivity\nimport androidx.compose.ui.test.assertIsSelected\nimport androidx.compose.ui.test.junit4.createAndroidComposeRule\nimport androidx.compose.ui.test.onNodeWithText\nimport com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.DARK\nimport com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.ANDROID\nimport com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.DEFAULT\nimport com.google.samples.apps.nowinandroid.feature.settings.impl.SettingsUiState.Loading\nimport com.google.samples.apps.nowinandroid.feature.settings.impl.SettingsUiState.Success\nimport org.junit.Rule\nimport org.junit.Test\n\nclass SettingsDialogTest {\n\n    @get:Rule\n    val composeTestRule = createAndroidComposeRule<ComponentActivity>()\n\n    private fun getString(id: Int) = composeTestRule.activity.resources.getString(id)\n\n    @Test\n    fun whenLoading_showsLoadingText() {\n        composeTestRule.setContent {\n            SettingsDialog(\n                settingsUiState = Loading,\n                onDismiss = {},\n                onChangeDynamicColorPreference = {},\n                onChangeThemeBrand = {},\n                onChangeDarkThemeConfig = {},\n            )\n        }\n\n        composeTestRule\n            .onNodeWithText(getString(R.string.feature_settings_impl_loading))\n            .assertExists()\n    }\n\n    @Test\n    fun whenStateIsSuccess_allDefaultSettingsAreDisplayed() {\n        composeTestRule.setContent {\n            SettingsDialog(\n                settingsUiState = Success(\n                    UserEditableSettings(\n                        brand = ANDROID,\n                        useDynamicColor = false,\n                        darkThemeConfig = DARK,\n                    ),\n                ),\n                onDismiss = { },\n                onChangeDynamicColorPreference = {},\n                onChangeThemeBrand = {},\n                onChangeDarkThemeConfig = {},\n            )\n        }\n\n        // Check that all the possible settings are displayed.\n        composeTestRule.onNodeWithText(getString(R.string.feature_settings_impl_brand_default)).assertExists()\n        composeTestRule.onNodeWithText(getString(R.string.feature_settings_impl_brand_android)).assertExists()\n        composeTestRule.onNodeWithText(\n            getString(R.string.feature_settings_impl_dark_mode_config_system_default),\n        ).assertExists()\n        composeTestRule.onNodeWithText(getString(R.string.feature_settings_impl_dark_mode_config_light)).assertExists()\n        composeTestRule.onNodeWithText(getString(R.string.feature_settings_impl_dark_mode_config_dark)).assertExists()\n\n        // Check that the correct settings are selected.\n        composeTestRule.onNodeWithText(getString(R.string.feature_settings_impl_brand_android)).assertIsSelected()\n        composeTestRule.onNodeWithText(getString(R.string.feature_settings_impl_dark_mode_config_dark)).assertIsSelected()\n    }\n\n    @Test\n    fun whenStateIsSuccess_supportsDynamicColor_usesDefaultBrand_DynamicColorOptionIsDisplayed() {\n        composeTestRule.setContent {\n            SettingsDialog(\n                settingsUiState = Success(\n                    UserEditableSettings(\n                        brand = DEFAULT,\n                        darkThemeConfig = DARK,\n                        useDynamicColor = false,\n                    ),\n                ),\n                supportDynamicColor = true,\n                onDismiss = {},\n                onChangeDynamicColorPreference = {},\n                onChangeThemeBrand = {},\n                onChangeDarkThemeConfig = {},\n            )\n        }\n\n        composeTestRule.onNodeWithText(getString(R.string.feature_settings_impl_dynamic_color_preference)).assertExists()\n        composeTestRule.onNodeWithText(getString(R.string.feature_settings_impl_dynamic_color_yes)).assertExists()\n        composeTestRule.onNodeWithText(getString(R.string.feature_settings_impl_dynamic_color_no)).assertExists()\n\n        // Check that the correct default dynamic color setting is selected.\n        composeTestRule.onNodeWithText(getString(R.string.feature_settings_impl_dynamic_color_no)).assertIsSelected()\n    }\n\n    @Test\n    fun whenStateIsSuccess_notSupportDynamicColor_DynamicColorOptionIsNotDisplayed() {\n        composeTestRule.setContent {\n            SettingsDialog(\n                settingsUiState = Success(\n                    UserEditableSettings(\n                        brand = ANDROID,\n                        darkThemeConfig = DARK,\n                        useDynamicColor = false,\n                    ),\n                ),\n                onDismiss = {},\n                onChangeDynamicColorPreference = {},\n                onChangeThemeBrand = {},\n                onChangeDarkThemeConfig = {},\n            )\n        }\n\n        composeTestRule.onNodeWithText(getString(R.string.feature_settings_impl_dynamic_color_preference))\n            .assertDoesNotExist()\n        composeTestRule.onNodeWithText(getString(R.string.feature_settings_impl_dynamic_color_yes)).assertDoesNotExist()\n        composeTestRule.onNodeWithText(getString(R.string.feature_settings_impl_dynamic_color_no)).assertDoesNotExist()\n    }\n\n    @Test\n    fun whenStateIsSuccess_usesAndroidBrand_DynamicColorOptionIsNotDisplayed() {\n        composeTestRule.setContent {\n            SettingsDialog(\n                settingsUiState = Success(\n                    UserEditableSettings(\n                        brand = ANDROID,\n                        darkThemeConfig = DARK,\n                        useDynamicColor = false,\n                    ),\n                ),\n                onDismiss = {},\n                onChangeDynamicColorPreference = {},\n                onChangeThemeBrand = {},\n                onChangeDarkThemeConfig = {},\n            )\n        }\n\n        composeTestRule.onNodeWithText(getString(R.string.feature_settings_impl_dynamic_color_preference))\n            .assertDoesNotExist()\n        composeTestRule.onNodeWithText(getString(R.string.feature_settings_impl_dynamic_color_yes)).assertDoesNotExist()\n        composeTestRule.onNodeWithText(getString(R.string.feature_settings_impl_dynamic_color_no)).assertDoesNotExist()\n    }\n\n    @Test\n    fun whenStateIsSuccess_allLinksAreDisplayed() {\n        composeTestRule.setContent {\n            SettingsDialog(\n                settingsUiState = Success(\n                    UserEditableSettings(\n                        brand = ANDROID,\n                        darkThemeConfig = DARK,\n                        useDynamicColor = false,\n                    ),\n                ),\n                onDismiss = {},\n                onChangeDynamicColorPreference = {},\n                onChangeThemeBrand = {},\n                onChangeDarkThemeConfig = {},\n            )\n        }\n\n        composeTestRule.onNodeWithText(getString(R.string.feature_settings_impl_privacy_policy)).assertExists()\n        composeTestRule.onNodeWithText(getString(R.string.feature_settings_impl_licenses)).assertExists()\n        composeTestRule.onNodeWithText(getString(R.string.feature_settings_impl_brand_guidelines)).assertExists()\n        composeTestRule.onNodeWithText(getString(R.string.feature_settings_impl_feedback)).assertExists()\n    }\n}\n"
  },
  {
    "path": "feature/settings/impl/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <application>\n        <activity\n            android:name=\"com.google.android.gms.oss.licenses.OssLicensesMenuActivity\"\n            android:theme=\"@style/Theme.AppCompat\" />\n\n        <activity\n            android:name=\"com.google.android.gms.oss.licenses.OssLicensesActivity\"\n            android:theme=\"@style/Theme.AppCompat\" />\n    </application>\n</manifest>\n"
  },
  {
    "path": "feature/settings/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/impl/SettingsDialog.kt",
    "content": "/*\n * Copyright 2025 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n@file:Suppress(\"ktlint:standard:max-line-length\")\n\npackage com.google.samples.apps.nowinandroid.feature.settings.impl\n\nimport android.content.Intent\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.ColumnScope\nimport androidx.compose.foundation.layout.ExperimentalLayoutApi\nimport androidx.compose.foundation.layout.FlowRow\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.layout.widthIn\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.selection.selectable\nimport androidx.compose.foundation.selection.selectableGroup\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material3.AlertDialog\nimport androidx.compose.material3.HorizontalDivider\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.RadioButton\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.getValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.platform.LocalConfiguration\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.platform.LocalUriHandler\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.semantics.Role\nimport androidx.compose.ui.tooling.preview.Preview\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.window.DialogProperties\nimport androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel\nimport androidx.lifecycle.compose.collectAsStateWithLifecycle\nimport com.google.android.gms.oss.licenses.OssLicensesMenuActivity\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTextButton\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.supportsDynamicTheming\nimport com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig\nimport com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.DARK\nimport com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.FOLLOW_SYSTEM\nimport com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.LIGHT\nimport com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand\nimport com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.ANDROID\nimport com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.DEFAULT\nimport com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent\nimport com.google.samples.apps.nowinandroid.feature.settings.impl.R.string\nimport com.google.samples.apps.nowinandroid.feature.settings.impl.SettingsUiState.Loading\nimport com.google.samples.apps.nowinandroid.feature.settings.impl.SettingsUiState.Success\n\n@Composable\nfun SettingsDialog(\n    onDismiss: () -> Unit,\n    viewModel: SettingsViewModel = hiltViewModel(),\n) {\n    val settingsUiState by viewModel.settingsUiState.collectAsStateWithLifecycle()\n    SettingsDialog(\n        onDismiss = onDismiss,\n        settingsUiState = settingsUiState,\n        onChangeThemeBrand = viewModel::updateThemeBrand,\n        onChangeDynamicColorPreference = viewModel::updateDynamicColorPreference,\n        onChangeDarkThemeConfig = viewModel::updateDarkThemeConfig,\n    )\n}\n\n@Composable\nfun SettingsDialog(\n    settingsUiState: SettingsUiState,\n    supportDynamicColor: Boolean = supportsDynamicTheming(),\n    onDismiss: () -> Unit,\n    onChangeThemeBrand: (themeBrand: ThemeBrand) -> Unit,\n    onChangeDynamicColorPreference: (useDynamicColor: Boolean) -> Unit,\n    onChangeDarkThemeConfig: (darkThemeConfig: DarkThemeConfig) -> Unit,\n) {\n    val configuration = LocalConfiguration.current\n\n    /**\n     * usePlatformDefaultWidth = false is use as a temporary fix to allow\n     * height recalculation during recomposition. This, however, causes\n     * Dialog's to occupy full width in Compact mode. Therefore max width\n     * is configured below. This should be removed when there's fix to\n     * https://issuetracker.google.com/issues/221643630\n     */\n    AlertDialog(\n        properties = DialogProperties(usePlatformDefaultWidth = false),\n        modifier = Modifier.widthIn(max = configuration.screenWidthDp.dp - 80.dp),\n        onDismissRequest = { onDismiss() },\n        title = {\n            Text(\n                text = stringResource(string.feature_settings_impl_title),\n                style = MaterialTheme.typography.titleLarge,\n            )\n        },\n        text = {\n            HorizontalDivider()\n            Column(Modifier.verticalScroll(rememberScrollState())) {\n                when (settingsUiState) {\n                    Loading -> {\n                        Text(\n                            text = stringResource(string.feature_settings_impl_loading),\n                            modifier = Modifier.padding(vertical = 16.dp),\n                        )\n                    }\n\n                    is Success -> {\n                        SettingsPanel(\n                            settings = settingsUiState.settings,\n                            supportDynamicColor = supportDynamicColor,\n                            onChangeThemeBrand = onChangeThemeBrand,\n                            onChangeDynamicColorPreference = onChangeDynamicColorPreference,\n                            onChangeDarkThemeConfig = onChangeDarkThemeConfig,\n                        )\n                    }\n                }\n                HorizontalDivider(Modifier.padding(top = 8.dp))\n                LinksPanel()\n            }\n            TrackScreenViewEvent(screenName = \"Settings\")\n        },\n        confirmButton = {\n            NiaTextButton(\n                onClick = onDismiss,\n                modifier = Modifier.padding(horizontal = 8.dp),\n            ) {\n                Text(\n                    text = stringResource(string.feature_settings_impl_dismiss_dialog_button_text),\n                    style = MaterialTheme.typography.labelLarge,\n                    color = MaterialTheme.colorScheme.primary,\n                )\n            }\n        },\n    )\n}\n\n// [ColumnScope] is used for using the [ColumnScope.AnimatedVisibility] extension overload composable.\n@Composable\nprivate fun ColumnScope.SettingsPanel(\n    settings: UserEditableSettings,\n    supportDynamicColor: Boolean,\n    onChangeThemeBrand: (themeBrand: ThemeBrand) -> Unit,\n    onChangeDynamicColorPreference: (useDynamicColor: Boolean) -> Unit,\n    onChangeDarkThemeConfig: (darkThemeConfig: DarkThemeConfig) -> Unit,\n) {\n    SettingsDialogSectionTitle(text = stringResource(string.feature_settings_impl_theme))\n    Column(Modifier.selectableGroup()) {\n        SettingsDialogThemeChooserRow(\n            text = stringResource(string.feature_settings_impl_brand_default),\n            selected = settings.brand == DEFAULT,\n            onClick = { onChangeThemeBrand(DEFAULT) },\n        )\n        SettingsDialogThemeChooserRow(\n            text = stringResource(string.feature_settings_impl_brand_android),\n            selected = settings.brand == ANDROID,\n            onClick = { onChangeThemeBrand(ANDROID) },\n        )\n    }\n    AnimatedVisibility(visible = settings.brand == DEFAULT && supportDynamicColor) {\n        Column {\n            SettingsDialogSectionTitle(text = stringResource(string.feature_settings_impl_dynamic_color_preference))\n            Column(Modifier.selectableGroup()) {\n                SettingsDialogThemeChooserRow(\n                    text = stringResource(string.feature_settings_impl_dynamic_color_yes),\n                    selected = settings.useDynamicColor,\n                    onClick = { onChangeDynamicColorPreference(true) },\n                )\n                SettingsDialogThemeChooserRow(\n                    text = stringResource(string.feature_settings_impl_dynamic_color_no),\n                    selected = !settings.useDynamicColor,\n                    onClick = { onChangeDynamicColorPreference(false) },\n                )\n            }\n        }\n    }\n    SettingsDialogSectionTitle(text = stringResource(string.feature_settings_impl_dark_mode_preference))\n    Column(Modifier.selectableGroup()) {\n        SettingsDialogThemeChooserRow(\n            text = stringResource(string.feature_settings_impl_dark_mode_config_system_default),\n            selected = settings.darkThemeConfig == FOLLOW_SYSTEM,\n            onClick = { onChangeDarkThemeConfig(FOLLOW_SYSTEM) },\n        )\n        SettingsDialogThemeChooserRow(\n            text = stringResource(string.feature_settings_impl_dark_mode_config_light),\n            selected = settings.darkThemeConfig == LIGHT,\n            onClick = { onChangeDarkThemeConfig(LIGHT) },\n        )\n        SettingsDialogThemeChooserRow(\n            text = stringResource(string.feature_settings_impl_dark_mode_config_dark),\n            selected = settings.darkThemeConfig == DARK,\n            onClick = { onChangeDarkThemeConfig(DARK) },\n        )\n    }\n}\n\n@Composable\nprivate fun SettingsDialogSectionTitle(text: String) {\n    Text(\n        text = text,\n        style = MaterialTheme.typography.titleMedium,\n        modifier = Modifier.padding(top = 16.dp, bottom = 8.dp),\n    )\n}\n\n@Composable\nfun SettingsDialogThemeChooserRow(\n    text: String,\n    selected: Boolean,\n    onClick: () -> Unit,\n) {\n    Row(\n        Modifier\n            .fillMaxWidth()\n            .selectable(\n                selected = selected,\n                role = Role.RadioButton,\n                onClick = onClick,\n            )\n            .padding(12.dp),\n        verticalAlignment = Alignment.CenterVertically,\n    ) {\n        RadioButton(\n            selected = selected,\n            onClick = null,\n        )\n        Spacer(Modifier.width(8.dp))\n        Text(text)\n    }\n}\n\n@OptIn(ExperimentalLayoutApi::class)\n@Composable\nprivate fun LinksPanel() {\n    FlowRow(\n        horizontalArrangement = Arrangement.spacedBy(\n            space = 16.dp,\n            alignment = Alignment.CenterHorizontally,\n        ),\n        modifier = Modifier.fillMaxWidth(),\n    ) {\n        val uriHandler = LocalUriHandler.current\n        NiaTextButton(\n            onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) },\n        ) {\n            Text(text = stringResource(string.feature_settings_impl_privacy_policy))\n        }\n        val context = LocalContext.current\n        NiaTextButton(\n            onClick = {\n                context.startActivity(Intent(context, OssLicensesMenuActivity::class.java))\n            },\n        ) {\n            Text(text = stringResource(string.feature_settings_impl_licenses))\n        }\n        NiaTextButton(\n            onClick = { uriHandler.openUri(BRAND_GUIDELINES_URL) },\n        ) {\n            Text(text = stringResource(string.feature_settings_impl_brand_guidelines))\n        }\n        NiaTextButton(\n            onClick = { uriHandler.openUri(FEEDBACK_URL) },\n        ) {\n            Text(text = stringResource(string.feature_settings_impl_feedback))\n        }\n    }\n}\n\n@Preview\n@Composable\nprivate fun PreviewSettingsDialog() {\n    NiaTheme {\n        SettingsDialog(\n            onDismiss = {},\n            settingsUiState = Success(\n                UserEditableSettings(\n                    brand = DEFAULT,\n                    darkThemeConfig = FOLLOW_SYSTEM,\n                    useDynamicColor = false,\n                ),\n            ),\n            onChangeThemeBrand = {},\n            onChangeDynamicColorPreference = {},\n            onChangeDarkThemeConfig = {},\n        )\n    }\n}\n\n@Preview\n@Composable\nprivate fun PreviewSettingsDialogLoading() {\n    NiaTheme {\n        SettingsDialog(\n            onDismiss = {},\n            settingsUiState = Loading,\n            onChangeThemeBrand = {},\n            onChangeDynamicColorPreference = {},\n            onChangeDarkThemeConfig = {},\n        )\n    }\n}\n\nprivate const val PRIVACY_POLICY_URL = \"https://policies.google.com/privacy\"\nprivate const val BRAND_GUIDELINES_URL = \"https://developer.android.com/distribute/marketing-tools/brand-guidelines\"\nprivate const val FEEDBACK_URL = \"https://goo.gle/nia-app-feedback\"\n"
  },
  {
    "path": "feature/settings/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/impl/SettingsViewModel.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.settings.impl\n\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository\nimport com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig\nimport com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand\nimport com.google.samples.apps.nowinandroid.feature.settings.impl.SettingsUiState.Loading\nimport com.google.samples.apps.nowinandroid.feature.settings.impl.SettingsUiState.Success\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.map\nimport kotlinx.coroutines.flow.stateIn\nimport kotlinx.coroutines.launch\nimport javax.inject.Inject\nimport kotlin.time.Duration.Companion.seconds\n\n@HiltViewModel\nclass SettingsViewModel @Inject constructor(\n    private val userDataRepository: UserDataRepository,\n) : ViewModel() {\n    val settingsUiState: StateFlow<SettingsUiState> =\n        userDataRepository.userData\n            .map { userData ->\n                Success(\n                    settings = UserEditableSettings(\n                        brand = userData.themeBrand,\n                        useDynamicColor = userData.useDynamicColor,\n                        darkThemeConfig = userData.darkThemeConfig,\n                    ),\n                )\n            }\n            .stateIn(\n                scope = viewModelScope,\n                started = WhileSubscribed(5.seconds.inWholeMilliseconds),\n                initialValue = Loading,\n            )\n\n    fun updateThemeBrand(themeBrand: ThemeBrand) {\n        viewModelScope.launch {\n            userDataRepository.setThemeBrand(themeBrand)\n        }\n    }\n\n    fun updateDarkThemeConfig(darkThemeConfig: DarkThemeConfig) {\n        viewModelScope.launch {\n            userDataRepository.setDarkThemeConfig(darkThemeConfig)\n        }\n    }\n\n    fun updateDynamicColorPreference(useDynamicColor: Boolean) {\n        viewModelScope.launch {\n            userDataRepository.setDynamicColorPreference(useDynamicColor)\n        }\n    }\n}\n\n/**\n * Represents the settings which the user can edit within the app.\n */\ndata class UserEditableSettings(\n    val brand: ThemeBrand,\n    val useDynamicColor: Boolean,\n    val darkThemeConfig: DarkThemeConfig,\n)\n\nsealed interface SettingsUiState {\n    data object Loading : SettingsUiState\n    data class Success(val settings: UserEditableSettings) : SettingsUiState\n}\n"
  },
  {
    "path": "feature/settings/impl/src/main/res/values/strings.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<resources>\n    <string name=\"feature_settings_impl_top_app_bar_action_icon_description\">Settings</string>\n    <string name=\"feature_settings_impl_top_app_bar_navigation_icon_description\">Search</string>\n    <string name=\"feature_settings_impl_title\">Settings</string>\n    <string name=\"feature_settings_impl_loading\">Loading…</string>\n    <string name=\"feature_settings_impl_privacy_policy\">Privacy policy</string>\n    <string name=\"feature_settings_impl_licenses\">Licenses</string>\n    <string name=\"feature_settings_impl_brand_guidelines\">Brand Guidelines</string>\n    <string name=\"feature_settings_impl_feedback\">Feedback</string>\n    <string name=\"feature_settings_impl_theme\">Theme</string>\n    <string name=\"feature_settings_impl_brand_default\">Default</string>\n    <string name=\"feature_settings_impl_brand_android\">Android</string>\n    <string name=\"feature_settings_impl_dark_mode_preference\">Dark mode preference</string>\n    <string name=\"feature_settings_impl_dark_mode_config_system_default\">System default</string>\n    <string name=\"feature_settings_impl_dark_mode_config_light\">Light</string>\n    <string name=\"feature_settings_impl_dark_mode_config_dark\">Dark</string>\n    <string name=\"feature_settings_impl_dynamic_color_preference\">Use Dynamic Color</string>\n    <string name=\"feature_settings_impl_dynamic_color_yes\">Yes</string>\n    <string name=\"feature_settings_impl_dynamic_color_no\">No</string>\n    <string name=\"feature_settings_impl_dismiss_dialog_button_text\">OK</string>\n</resources>\n"
  },
  {
    "path": "feature/settings/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/settings/impl/SettingsViewModelTest.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.settings.impl\n\nimport com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.DARK\nimport com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.ANDROID\nimport com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository\nimport com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule\nimport com.google.samples.apps.nowinandroid.feature.settings.impl.SettingsUiState.Loading\nimport com.google.samples.apps.nowinandroid.feature.settings.impl.SettingsUiState.Success\nimport kotlinx.coroutines.flow.collect\nimport kotlinx.coroutines.launch\nimport kotlinx.coroutines.test.UnconfinedTestDispatcher\nimport kotlinx.coroutines.test.runTest\nimport org.junit.Before\nimport org.junit.Rule\nimport org.junit.Test\nimport kotlin.test.assertEquals\n\nclass SettingsViewModelTest {\n\n    @get:Rule\n    val mainDispatcherRule = MainDispatcherRule()\n\n    private val userDataRepository = TestUserDataRepository()\n\n    private lateinit var viewModel: SettingsViewModel\n\n    @Before\n    fun setup() {\n        viewModel = SettingsViewModel(userDataRepository)\n    }\n\n    @Test\n    fun stateIsInitiallyLoading() = runTest {\n        assertEquals(Loading, viewModel.settingsUiState.value)\n    }\n\n    @Test\n    fun stateIsSuccessAfterUserDataLoaded() = runTest {\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.settingsUiState.collect() }\n\n        userDataRepository.setThemeBrand(ANDROID)\n        userDataRepository.setDarkThemeConfig(DARK)\n\n        assertEquals(\n            Success(\n                UserEditableSettings(\n                    brand = ANDROID,\n                    darkThemeConfig = DARK,\n                    useDynamicColor = false,\n                ),\n            ),\n            viewModel.settingsUiState.value,\n        )\n    }\n}\n"
  },
  {
    "path": "feature/topic/api/.gitignore",
    "content": "/build"
  },
  {
    "path": "feature/topic/api/README.md",
    "content": "# `:feature:topic:api`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :feature\n    direction TB\n    subgraph :feature:topic\n      direction TB\n      :feature:topic:api[api]:::android-library\n    end\n  end\n  subgraph :core\n    direction TB\n    :core:analytics[analytics]:::android-library\n    :core:designsystem[designsystem]:::android-library\n    :core:model[model]:::jvm-library\n    :core:navigation[navigation]:::android-library\n    :core:ui[ui]:::android-library\n  end\n\n  :core:ui --> :core:analytics\n  :core:ui --> :core:designsystem\n  :core:ui --> :core:model\n  :feature:topic:api -.-> :core:designsystem\n  :feature:topic:api --> :core:navigation\n  :feature:topic:api -.-> :core:ui\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "feature/topic/api/build.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nplugins {\n    alias(libs.plugins.nowinandroid.android.feature.api)\n    alias(libs.plugins.nowinandroid.android.feature.impl)\n    alias(libs.plugins.nowinandroid.android.library.compose)\n}\n\nandroid {\n    namespace = \"com.google.samples.apps.nowinandroid.feature.topic.api\"\n}\n"
  },
  {
    "path": "feature/topic/api/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<manifest />\n"
  },
  {
    "path": "feature/topic/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/navigation/TopicNavKey.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.topic.api.navigation\n\nimport androidx.navigation3.runtime.NavKey\nimport com.google.samples.apps.nowinandroid.core.navigation.Navigator\nimport kotlinx.serialization.Serializable\n\n@Serializable\ndata class TopicNavKey(val id: String) : NavKey\n\nfun Navigator.navigateToTopic(\n    topicId: String,\n) {\n    navigate(TopicNavKey(topicId))\n}\n"
  },
  {
    "path": "feature/topic/api/src/main/res/values/strings.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<resources>\n    <string name=\"feature_topic_api_loading\">Loading topic</string>\n</resources>\n"
  },
  {
    "path": "feature/topic/impl/.gitignore",
    "content": "/build"
  },
  {
    "path": "feature/topic/impl/README.md",
    "content": "# `:feature:topic:impl`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :feature\n    direction TB\n    subgraph :feature:topic\n      direction TB\n      :feature:topic:api[api]:::android-library\n      :feature:topic:impl[impl]:::android-library\n    end\n  end\n  subgraph :core\n    direction TB\n    :core:analytics[analytics]:::android-library\n    :core:common[common]:::jvm-library\n    :core:data[data]:::android-library\n    :core:database[database]:::android-library\n    :core:datastore[datastore]:::android-library\n    :core:datastore-proto[datastore-proto]:::android-library\n    :core:designsystem[designsystem]:::android-library\n    :core:model[model]:::jvm-library\n    :core:navigation[navigation]:::android-library\n    :core:network[network]:::android-library\n    :core:notifications[notifications]:::android-library\n    :core:ui[ui]:::android-library\n  end\n\n  :core:data -.-> :core:analytics\n  :core:data --> :core:common\n  :core:data --> :core:database\n  :core:data --> :core:datastore\n  :core:data --> :core:network\n  :core:data -.-> :core:notifications\n  :core:database --> :core:model\n  :core:datastore -.-> :core:common\n  :core:datastore --> :core:datastore-proto\n  :core:datastore --> :core:model\n  :core:network --> :core:common\n  :core:network --> :core:model\n  :core:notifications -.-> :core:common\n  :core:notifications --> :core:model\n  :core:ui --> :core:analytics\n  :core:ui --> :core:designsystem\n  :core:ui --> :core:model\n  :feature:topic:api -.-> :core:designsystem\n  :feature:topic:api --> :core:navigation\n  :feature:topic:api -.-> :core:ui\n  :feature:topic:impl -.-> :core:data\n  :feature:topic:impl -.-> :core:designsystem\n  :feature:topic:impl -.-> :core:ui\n  :feature:topic:impl -.-> :feature:topic:api\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "feature/topic/impl/build.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nplugins {\n    alias(libs.plugins.nowinandroid.android.feature.impl)\n    alias(libs.plugins.nowinandroid.android.library.compose)\n    alias(libs.plugins.nowinandroid.android.library.jacoco)\n}\n\nandroid {\n    namespace = \"com.google.samples.apps.nowinandroid.feature.topic.impl\"\n}\n\ndependencies {\n    implementation(projects.core.data)\n    implementation(projects.feature.topic.api)\n\n    implementation(libs.androidx.compose.material3.adaptive.navigation3)\n\n    testImplementation(projects.core.testing)\n    testImplementation(libs.robolectric)\n\n    androidTestImplementation(libs.bundles.androidx.compose.ui.test)\n    androidTestImplementation(projects.core.testing)\n}\n"
  },
  {
    "path": "feature/topic/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/TopicScreenTest.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.topic.impl\n\nimport androidx.activity.ComponentActivity\nimport androidx.compose.ui.test.hasScrollToNodeAction\nimport androidx.compose.ui.test.hasText\nimport androidx.compose.ui.test.junit4.createAndroidComposeRule\nimport androidx.compose.ui.test.onFirst\nimport androidx.compose.ui.test.onNodeWithContentDescription\nimport androidx.compose.ui.test.onNodeWithText\nimport androidx.compose.ui.test.performScrollToNode\nimport com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData\nimport com.google.samples.apps.nowinandroid.core.testing.data.userNewsResourcesTestData\nimport com.google.samples.apps.nowinandroid.feature.topic.api.R\nimport org.junit.Before\nimport org.junit.Rule\nimport org.junit.Test\n\n/**\n * UI test for checking the correct behaviour of the Topic screen;\n * Verifies that, when a specific UiState is set, the corresponding\n * composables and details are shown\n */\nclass TopicScreenTest {\n\n    @get:Rule\n    val composeTestRule = createAndroidComposeRule<ComponentActivity>()\n\n    private lateinit var topicLoading: String\n\n    @Before\n    fun setup() {\n        composeTestRule.activity.apply {\n            topicLoading = getString(R.string.feature_topic_api_loading)\n        }\n    }\n\n    @Test\n    fun niaLoadingWheel_whenScreenIsLoading_showLoading() {\n        composeTestRule.setContent {\n            TopicScreen(\n                topicUiState = TopicUiState.Loading,\n                newsUiState = NewsUiState.Loading,\n                showBackButton = true,\n                onBackClick = {},\n                onFollowClick = {},\n                onTopicClick = {},\n                onBookmarkChanged = { _, _ -> },\n                onNewsResourceViewed = {},\n            )\n        }\n\n        composeTestRule\n            .onNodeWithContentDescription(topicLoading)\n            .assertExists()\n    }\n\n    @Test\n    fun topicTitle_whenTopicIsSuccess_isShown() {\n        val testTopic = followableTopicTestData.first()\n        composeTestRule.setContent {\n            TopicScreen(\n                topicUiState = TopicUiState.Success(testTopic),\n                newsUiState = NewsUiState.Loading,\n                showBackButton = true,\n                onBackClick = {},\n                onFollowClick = {},\n                onTopicClick = {},\n                onBookmarkChanged = { _, _ -> },\n                onNewsResourceViewed = {},\n            )\n        }\n\n        // Name is shown\n        composeTestRule\n            .onNodeWithText(testTopic.topic.name)\n            .assertExists()\n\n        // Description is shown\n        composeTestRule\n            .onNodeWithText(testTopic.topic.longDescription)\n            .assertExists()\n    }\n\n    @Test\n    fun news_whenTopicIsLoading_isNotShown() {\n        composeTestRule.setContent {\n            TopicScreen(\n                topicUiState = TopicUiState.Loading,\n                newsUiState = NewsUiState.Success(userNewsResourcesTestData),\n                showBackButton = true,\n                onBackClick = {},\n                onFollowClick = {},\n                onTopicClick = {},\n                onBookmarkChanged = { _, _ -> },\n                onNewsResourceViewed = {},\n            )\n        }\n\n        // Loading indicator shown\n        composeTestRule\n            .onNodeWithContentDescription(topicLoading)\n            .assertExists()\n    }\n\n    @Test\n    fun news_whenSuccessAndTopicIsSuccess_isShown() {\n        val testTopic = followableTopicTestData.first()\n        composeTestRule.setContent {\n            TopicScreen(\n                topicUiState = TopicUiState.Success(testTopic),\n                newsUiState = NewsUiState.Success(\n                    userNewsResourcesTestData,\n                ),\n                showBackButton = true,\n                onBackClick = {},\n                onFollowClick = {},\n                onTopicClick = {},\n                onBookmarkChanged = { _, _ -> },\n                onNewsResourceViewed = {},\n            )\n        }\n\n        // Scroll to first news title if available\n        composeTestRule\n            .onAllNodes(hasScrollToNodeAction())\n            .onFirst()\n            .performScrollToNode(hasText(userNewsResourcesTestData.first().title))\n    }\n}\n"
  },
  {
    "path": "feature/topic/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/TopicScreen.kt",
    "content": "/*\n * Copyright 2021 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.topic.impl\n\nimport androidx.annotation.VisibleForTesting\nimport androidx.compose.foundation.gestures.Orientation\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.WindowInsets\nimport androidx.compose.foundation.layout.fillMaxHeight\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.safeDrawing\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.layout.systemBars\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.layout.windowInsetsBottomHeight\nimport androidx.compose.foundation.layout.windowInsetsPadding\nimport androidx.compose.foundation.layout.windowInsetsTopHeight\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.LazyListScope\nimport androidx.compose.foundation.lazy.rememberLazyListState\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.getValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.platform.testTag\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.tooling.preview.Preview\nimport androidx.compose.ui.tooling.preview.PreviewParameter\nimport androidx.compose.ui.unit.dp\nimport androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel\nimport androidx.lifecycle.compose.collectAsStateWithLifecycle\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.DynamicAsyncImage\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilterChip\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.DraggableScrollbar\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.rememberDraggableScroller\nimport com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.scrollbarState\nimport com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons\nimport com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme\nimport com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic\nimport com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource\nimport com.google.samples.apps.nowinandroid.core.ui.DevicePreviews\nimport com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent\nimport com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank\nimport com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider\nimport com.google.samples.apps.nowinandroid.core.ui.userNewsResourceCardItems\nimport com.google.samples.apps.nowinandroid.core.ui.R as UiR\nimport com.google.samples.apps.nowinandroid.feature.topic.api.R as TopicR\n\n@Composable\nfun TopicScreen(\n    showBackButton: Boolean,\n    onBackClick: () -> Unit,\n    onTopicClick: (String) -> Unit,\n    modifier: Modifier = Modifier,\n    viewModel: TopicViewModel = hiltViewModel(),\n) {\n    val topicUiState: TopicUiState by viewModel.topicUiState.collectAsStateWithLifecycle()\n    val newsUiState: NewsUiState by viewModel.newsUiState.collectAsStateWithLifecycle()\n\n    TrackScreenViewEvent(screenName = \"Topic: ${viewModel.topicId}\")\n    TopicScreen(\n        topicUiState = topicUiState,\n        newsUiState = newsUiState,\n        modifier = modifier.testTag(\"topic:${viewModel.topicId}\"),\n        showBackButton = showBackButton,\n        onBackClick = onBackClick,\n        onFollowClick = viewModel::followTopicToggle,\n        onBookmarkChanged = viewModel::bookmarkNews,\n        onNewsResourceViewed = { viewModel.setNewsResourceViewed(it, true) },\n        onTopicClick = onTopicClick,\n    )\n}\n\n@VisibleForTesting\n@Composable\ninternal fun TopicScreen(\n    topicUiState: TopicUiState,\n    newsUiState: NewsUiState,\n    showBackButton: Boolean,\n    onBackClick: () -> Unit,\n    onFollowClick: (Boolean) -> Unit,\n    onTopicClick: (String) -> Unit,\n    onBookmarkChanged: (String, Boolean) -> Unit,\n    onNewsResourceViewed: (String) -> Unit,\n    modifier: Modifier = Modifier,\n) {\n    val state = rememberLazyListState()\n    TrackScrollJank(scrollableState = state, stateName = \"topic:screen\")\n    Box(\n        modifier = modifier,\n    ) {\n        LazyColumn(\n            state = state,\n            horizontalAlignment = Alignment.CenterHorizontally,\n        ) {\n            item {\n                Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing))\n            }\n            when (topicUiState) {\n                TopicUiState.Loading -> item {\n                    NiaLoadingWheel(\n                        modifier = modifier,\n                        contentDesc = stringResource(id = TopicR.string.feature_topic_api_loading),\n                    )\n                }\n\n                TopicUiState.Error -> TODO()\n                is TopicUiState.Success -> {\n                    item {\n                        TopicToolbar(\n                            showBackButton = showBackButton,\n                            onBackClick = onBackClick,\n                            onFollowClick = onFollowClick,\n                            uiState = topicUiState.followableTopic,\n                        )\n                    }\n                    topicBody(\n                        name = topicUiState.followableTopic.topic.name,\n                        description = topicUiState.followableTopic.topic.longDescription,\n                        news = newsUiState,\n                        imageUrl = topicUiState.followableTopic.topic.imageUrl,\n                        onBookmarkChanged = onBookmarkChanged,\n                        onNewsResourceViewed = onNewsResourceViewed,\n                        onTopicClick = onTopicClick,\n                    )\n                }\n            }\n            item {\n                Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))\n            }\n        }\n        val itemsAvailable = topicItemsSize(topicUiState, newsUiState)\n        val scrollbarState = state.scrollbarState(\n            itemsAvailable = itemsAvailable,\n        )\n        state.DraggableScrollbar(\n            modifier = Modifier\n                .fillMaxHeight()\n                .windowInsetsPadding(WindowInsets.systemBars)\n                .padding(horizontal = 2.dp)\n                .align(Alignment.CenterEnd),\n            state = scrollbarState,\n            orientation = Orientation.Vertical,\n            onThumbMoved = state.rememberDraggableScroller(\n                itemsAvailable = itemsAvailable,\n            ),\n        )\n    }\n}\n\nprivate fun topicItemsSize(\n    topicUiState: TopicUiState,\n    newsUiState: NewsUiState,\n) = when (topicUiState) {\n    TopicUiState.Error -> 0 // Nothing\n    TopicUiState.Loading -> 1 // Loading bar\n    is TopicUiState.Success -> when (newsUiState) {\n        NewsUiState.Error -> 0 // Nothing\n        NewsUiState.Loading -> 1 // Loading bar\n        is NewsUiState.Success -> 2 + newsUiState.news.size // Toolbar, header\n    }\n}\n\nprivate fun LazyListScope.topicBody(\n    name: String,\n    description: String,\n    news: NewsUiState,\n    imageUrl: String,\n    onBookmarkChanged: (String, Boolean) -> Unit,\n    onNewsResourceViewed: (String) -> Unit,\n    onTopicClick: (String) -> Unit,\n) {\n    // TODO: Show icon if available\n    item {\n        TopicHeader(name, description, imageUrl)\n    }\n\n    userNewsResourceCards(news, onBookmarkChanged, onNewsResourceViewed, onTopicClick)\n}\n\n@Composable\nprivate fun TopicHeader(name: String, description: String, imageUrl: String) {\n    Column(\n        modifier = Modifier.padding(horizontal = 24.dp),\n    ) {\n        DynamicAsyncImage(\n            imageUrl = imageUrl,\n            contentDescription = null,\n            modifier = Modifier\n                .align(Alignment.CenterHorizontally)\n                .size(132.dp)\n                .padding(bottom = 12.dp),\n        )\n        Text(name, style = MaterialTheme.typography.displayMedium)\n        if (description.isNotEmpty()) {\n            Text(\n                description,\n                modifier = Modifier.padding(top = 24.dp),\n                style = MaterialTheme.typography.bodyLarge,\n            )\n        }\n    }\n}\n\n// TODO: Could/should this be replaced with [LazyGridScope.newsFeed]?\nprivate fun LazyListScope.userNewsResourceCards(\n    news: NewsUiState,\n    onBookmarkChanged: (String, Boolean) -> Unit,\n    onNewsResourceViewed: (String) -> Unit,\n    onTopicClick: (String) -> Unit,\n) {\n    when (news) {\n        is NewsUiState.Success -> {\n            userNewsResourceCardItems(\n                items = news.news,\n                onToggleBookmark = { onBookmarkChanged(it.id, !it.isSaved) },\n                onNewsResourceViewed = onNewsResourceViewed,\n                onTopicClick = onTopicClick,\n                itemModifier = Modifier.padding(24.dp),\n            )\n        }\n\n        is NewsUiState.Loading -> item {\n            NiaLoadingWheel(contentDesc = \"Loading news\") // TODO\n        }\n\n        else -> item {\n            Text(\"Error\") // TODO\n        }\n    }\n}\n\n@Preview\n@Composable\nprivate fun TopicBodyPreview() {\n    NiaTheme {\n        LazyColumn {\n            topicBody(\n                name = \"Jetpack Compose\",\n                description = \"Lorem ipsum maximum\",\n                news = NewsUiState.Success(emptyList()),\n                imageUrl = \"\",\n                onBookmarkChanged = { _, _ -> },\n                onNewsResourceViewed = {},\n                onTopicClick = {},\n            )\n        }\n    }\n}\n\n@Composable\nprivate fun TopicToolbar(\n    uiState: FollowableTopic,\n    modifier: Modifier = Modifier,\n    showBackButton: Boolean = true,\n    onBackClick: () -> Unit = {},\n    onFollowClick: (Boolean) -> Unit = {},\n) {\n    Row(\n        horizontalArrangement = Arrangement.SpaceBetween,\n        verticalAlignment = Alignment.CenterVertically,\n        modifier = modifier\n            .fillMaxWidth()\n            .padding(bottom = 32.dp),\n    ) {\n        if (showBackButton) {\n            IconButton(onClick = { onBackClick() }) {\n                Icon(\n                    imageVector = NiaIcons.ArrowBack,\n                    contentDescription = stringResource(\n                        id = UiR.string.core_ui_back,\n                    ),\n                )\n            }\n        } else {\n            // Keeps the NiaFilterChip aligned to the end of the Row.\n            Spacer(modifier = Modifier.width(1.dp))\n        }\n        val selected = uiState.isFollowed\n        NiaFilterChip(\n            selected = selected,\n            onSelectedChange = onFollowClick,\n            modifier = Modifier.padding(end = 24.dp),\n        ) {\n            if (selected) {\n                Text(\"FOLLOWING\")\n            } else {\n                Text(\"NOT FOLLOWING\")\n            }\n        }\n    }\n}\n\n@DevicePreviews\n@Composable\nfun TopicScreenPopulated(\n    @PreviewParameter(UserNewsResourcePreviewParameterProvider::class)\n    userNewsResources: List<UserNewsResource>,\n) {\n    NiaTheme {\n        NiaBackground {\n            TopicScreen(\n                topicUiState = TopicUiState.Success(userNewsResources[0].followableTopics[0]),\n                newsUiState = NewsUiState.Success(userNewsResources),\n                showBackButton = true,\n                onBackClick = {},\n                onFollowClick = {},\n                onBookmarkChanged = { _, _ -> },\n                onNewsResourceViewed = {},\n                onTopicClick = {},\n            )\n        }\n    }\n}\n\n@DevicePreviews\n@Composable\nfun TopicScreenLoading() {\n    NiaTheme {\n        NiaBackground {\n            TopicScreen(\n                topicUiState = TopicUiState.Loading,\n                newsUiState = NewsUiState.Loading,\n                showBackButton = true,\n                onBackClick = {},\n                onFollowClick = {},\n                onBookmarkChanged = { _, _ -> },\n                onNewsResourceViewed = {},\n                onTopicClick = {},\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "feature/topic/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/TopicViewModel.kt",
    "content": "/*\n * Copyright 2021 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.topic.impl\n\nimport androidx.lifecycle.ViewModel\nimport androidx.lifecycle.viewModelScope\nimport com.google.samples.apps.nowinandroid.core.common.result.Result\nimport com.google.samples.apps.nowinandroid.core.common.result.asResult\nimport com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery\nimport com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository\nimport com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic\nimport com.google.samples.apps.nowinandroid.core.model.data.Topic\nimport com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource\nimport dagger.assisted.Assisted\nimport dagger.assisted.AssistedFactory\nimport dagger.assisted.AssistedInject\nimport dagger.hilt.android.lifecycle.HiltViewModel\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.SharingStarted\nimport kotlinx.coroutines.flow.StateFlow\nimport kotlinx.coroutines.flow.combine\nimport kotlinx.coroutines.flow.map\nimport kotlinx.coroutines.flow.stateIn\nimport kotlinx.coroutines.launch\n\n@HiltViewModel(assistedFactory = TopicViewModel.Factory::class)\nclass TopicViewModel @AssistedInject constructor(\n    private val userDataRepository: UserDataRepository,\n    topicsRepository: TopicsRepository,\n    userNewsResourceRepository: UserNewsResourceRepository,\n    @Assisted val topicId: String,\n) : ViewModel() {\n    val topicUiState: StateFlow<TopicUiState> = topicUiState(\n        topicId = topicId,\n        userDataRepository = userDataRepository,\n        topicsRepository = topicsRepository,\n    )\n        .stateIn(\n            scope = viewModelScope,\n            started = SharingStarted.WhileSubscribed(5_000),\n            initialValue = TopicUiState.Loading,\n        )\n\n    val newsUiState: StateFlow<NewsUiState> = newsUiState(\n        topicId = topicId,\n        userDataRepository = userDataRepository,\n        userNewsResourceRepository = userNewsResourceRepository,\n    )\n        .stateIn(\n            scope = viewModelScope,\n            started = SharingStarted.WhileSubscribed(5_000),\n            initialValue = NewsUiState.Loading,\n        )\n\n    fun followTopicToggle(followed: Boolean) {\n        viewModelScope.launch {\n            userDataRepository.setTopicIdFollowed(topicId, followed)\n        }\n    }\n\n    fun bookmarkNews(newsResourceId: String, bookmarked: Boolean) {\n        viewModelScope.launch {\n            userDataRepository.setNewsResourceBookmarked(newsResourceId, bookmarked)\n        }\n    }\n\n    fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) {\n        viewModelScope.launch {\n            userDataRepository.setNewsResourceViewed(newsResourceId, viewed)\n        }\n    }\n\n    @AssistedFactory\n    interface Factory {\n        fun create(\n            topicId: String,\n        ): TopicViewModel\n    }\n}\n\nprivate fun topicUiState(\n    topicId: String,\n    userDataRepository: UserDataRepository,\n    topicsRepository: TopicsRepository,\n): Flow<TopicUiState> {\n    // Observe the followed topics, as they could change over time.\n    val followedTopicIds: Flow<Set<String>> =\n        userDataRepository.userData\n            .map { it.followedTopics }\n\n    // Observe topic information\n    val topicStream: Flow<Topic> = topicsRepository.getTopic(\n        id = topicId,\n    )\n\n    return combine(\n        followedTopicIds,\n        topicStream,\n        ::Pair,\n    )\n        .asResult()\n        .map { followedTopicToTopicResult ->\n            when (followedTopicToTopicResult) {\n                is Result.Success -> {\n                    val (followedTopics, topic) = followedTopicToTopicResult.data\n                    TopicUiState.Success(\n                        followableTopic = FollowableTopic(\n                            topic = topic,\n                            isFollowed = topicId in followedTopics,\n                        ),\n                    )\n                }\n\n                is Result.Loading -> TopicUiState.Loading\n                is Result.Error -> TopicUiState.Error\n            }\n        }\n}\n\nprivate fun newsUiState(\n    topicId: String,\n    userNewsResourceRepository: UserNewsResourceRepository,\n    userDataRepository: UserDataRepository,\n): Flow<NewsUiState> {\n    // Observe news\n    val newsStream: Flow<List<UserNewsResource>> = userNewsResourceRepository.observeAll(\n        NewsResourceQuery(filterTopicIds = setOf(element = topicId)),\n    )\n\n    // Observe bookmarks\n    val bookmark: Flow<Set<String>> = userDataRepository.userData\n        .map { it.bookmarkedNewsResources }\n\n    return combine(newsStream, bookmark, ::Pair)\n        .asResult()\n        .map { newsToBookmarksResult ->\n            when (newsToBookmarksResult) {\n                is Result.Success -> NewsUiState.Success(newsToBookmarksResult.data.first)\n                is Result.Loading -> NewsUiState.Loading\n                is Result.Error -> NewsUiState.Error\n            }\n        }\n}\n\nsealed interface TopicUiState {\n    data class Success(val followableTopic: FollowableTopic) : TopicUiState\n    data object Error : TopicUiState\n    data object Loading : TopicUiState\n}\n\nsealed interface NewsUiState {\n    data class Success(val news: List<UserNewsResource>) : NewsUiState\n    data object Error : NewsUiState\n    data object Loading : NewsUiState\n}\n"
  },
  {
    "path": "feature/topic/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/navigation/TopicEntryProvider.kt",
    "content": "/*\n * Copyright 2025 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.topic.impl.navigation\n\nimport androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi\nimport androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy\nimport androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel\nimport androidx.navigation3.runtime.EntryProviderScope\nimport androidx.navigation3.runtime.NavKey\nimport com.google.samples.apps.nowinandroid.core.navigation.Navigator\nimport com.google.samples.apps.nowinandroid.feature.topic.api.navigation.TopicNavKey\nimport com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic\nimport com.google.samples.apps.nowinandroid.feature.topic.impl.TopicScreen\nimport com.google.samples.apps.nowinandroid.feature.topic.impl.TopicViewModel\nimport com.google.samples.apps.nowinandroid.feature.topic.impl.TopicViewModel.Factory\n\n@OptIn(ExperimentalMaterial3AdaptiveApi::class)\nfun EntryProviderScope<NavKey>.topicEntry(navigator: Navigator) {\n    entry<TopicNavKey>(\n        metadata = ListDetailSceneStrategy.detailPane(),\n    ) { key ->\n        val id = key.id\n        TopicScreen(\n            showBackButton = true,\n            onBackClick = { navigator.goBack() },\n            onTopicClick = navigator::navigateToTopic,\n            viewModel = hiltViewModel<TopicViewModel, Factory>(\n                key = id,\n            ) { factory ->\n                factory.create(id)\n            },\n        )\n    }\n}\n"
  },
  {
    "path": "feature/topic/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/TopicViewModelTest.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.feature.topic.impl\n\nimport com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository\nimport com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic\nimport com.google.samples.apps.nowinandroid.core.model.data.NewsResource\nimport com.google.samples.apps.nowinandroid.core.model.data.Topic\nimport com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository\nimport com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository\nimport com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository\nimport com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule\nimport kotlinx.coroutines.flow.collect\nimport kotlinx.coroutines.flow.combine\nimport kotlinx.coroutines.flow.first\nimport kotlinx.coroutines.launch\nimport kotlinx.coroutines.test.UnconfinedTestDispatcher\nimport kotlinx.coroutines.test.runTest\nimport kotlinx.datetime.Instant\nimport org.junit.Before\nimport org.junit.Rule\nimport org.junit.Test\nimport kotlin.test.assertEquals\nimport kotlin.test.assertIs\n\n/**\n * To learn more about how this test handles Flows created with stateIn, see\n * https://developer.android.com/kotlin/flow/test#statein\n */\nclass TopicViewModelTest {\n\n    @get:Rule\n    val dispatcherRule = MainDispatcherRule()\n\n    private val userDataRepository = TestUserDataRepository()\n    private val topicsRepository = TestTopicsRepository()\n    private val newsRepository = TestNewsRepository()\n    private val userNewsResourceRepository = CompositeUserNewsResourceRepository(\n        newsRepository = newsRepository,\n        userDataRepository = userDataRepository,\n    )\n    private lateinit var viewModel: TopicViewModel\n\n    @Before\n    fun setup() {\n        viewModel = TopicViewModel(\n            userDataRepository = userDataRepository,\n            topicsRepository = topicsRepository,\n            userNewsResourceRepository = userNewsResourceRepository,\n            topicId = testInputTopics[0].topic.id,\n        )\n    }\n\n    @Test\n    fun topicId_matchesTopicIdFromSavedStateHandle() =\n        assertEquals(testInputTopics[0].topic.id, viewModel.topicId)\n\n    @Test\n    fun uiStateTopic_whenSuccess_matchesTopicFromRepository() = runTest {\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() }\n\n        topicsRepository.sendTopics(testInputTopics.map(FollowableTopic::topic))\n        userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))\n        val item = viewModel.topicUiState.value\n        assertIs<TopicUiState.Success>(item)\n\n        val topicFromRepository = topicsRepository.getTopic(\n            testInputTopics[0].topic.id,\n        ).first()\n\n        assertEquals(topicFromRepository, item.followableTopic.topic)\n    }\n\n    @Test\n    fun uiStateNews_whenInitialized_thenShowLoading() = runTest {\n        assertEquals(NewsUiState.Loading, viewModel.newsUiState.value)\n    }\n\n    @Test\n    fun uiStateTopic_whenInitialized_thenShowLoading() = runTest {\n        assertEquals(TopicUiState.Loading, viewModel.topicUiState.value)\n    }\n\n    @Test\n    fun uiStateTopic_whenFollowedIdsSuccessAndTopicLoading_thenShowLoading() = runTest {\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() }\n\n        userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))\n        assertEquals(TopicUiState.Loading, viewModel.topicUiState.value)\n    }\n\n    @Test\n    fun uiStateTopic_whenFollowedIdsSuccessAndTopicSuccess_thenTopicSuccessAndNewsLoading() =\n        runTest {\n            backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() }\n\n            topicsRepository.sendTopics(testInputTopics.map { it.topic })\n            userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))\n            val topicUiState = viewModel.topicUiState.value\n            val newsUiState = viewModel.newsUiState.value\n\n            assertIs<TopicUiState.Success>(topicUiState)\n            assertIs<NewsUiState.Loading>(newsUiState)\n        }\n\n    @Test\n    fun uiStateTopic_whenFollowedIdsSuccessAndTopicSuccessAndNewsIsSuccess_thenAllSuccess() =\n        runTest {\n            backgroundScope.launch(UnconfinedTestDispatcher()) {\n                combine(\n                    viewModel.topicUiState,\n                    viewModel.newsUiState,\n                    ::Pair,\n                ).collect()\n            }\n            topicsRepository.sendTopics(testInputTopics.map { it.topic })\n            userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))\n            newsRepository.sendNewsResources(sampleNewsResources)\n            val topicUiState = viewModel.topicUiState.value\n            val newsUiState = viewModel.newsUiState.value\n\n            assertIs<TopicUiState.Success>(topicUiState)\n            assertIs<NewsUiState.Success>(newsUiState)\n        }\n\n    @Test\n    fun uiStateTopic_whenFollowingTopic_thenShowUpdatedTopic() = runTest {\n        backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() }\n\n        topicsRepository.sendTopics(testInputTopics.map { it.topic })\n        // Set which topic IDs are followed, not including 0.\n        userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))\n\n        viewModel.followTopicToggle(true)\n\n        assertEquals(\n            TopicUiState.Success(followableTopic = testOutputTopics[0]),\n            viewModel.topicUiState.value,\n        )\n    }\n}\n\nprivate const val TOPIC_1_NAME = \"Android Studio\"\nprivate const val TOPIC_2_NAME = \"Build\"\nprivate const val TOPIC_3_NAME = \"Compose\"\nprivate const val TOPIC_SHORT_DESC = \"At vero eos et accusamus.\"\nprivate const val TOPIC_LONG_DESC = \"At vero eos et accusamus et iusto odio dignissimos ducimus.\"\nprivate const val TOPIC_URL = \"URL\"\nprivate const val TOPIC_IMAGE_URL = \"Image URL\"\n\nprivate val testInputTopics = listOf(\n    FollowableTopic(\n        Topic(\n            id = \"0\",\n            name = TOPIC_1_NAME,\n            shortDescription = TOPIC_SHORT_DESC,\n            longDescription = TOPIC_LONG_DESC,\n            url = TOPIC_URL,\n            imageUrl = TOPIC_IMAGE_URL,\n        ),\n        isFollowed = true,\n    ),\n    FollowableTopic(\n        Topic(\n            id = \"1\",\n            name = TOPIC_2_NAME,\n            shortDescription = TOPIC_SHORT_DESC,\n            longDescription = TOPIC_LONG_DESC,\n            url = TOPIC_URL,\n            imageUrl = TOPIC_IMAGE_URL,\n        ),\n        isFollowed = false,\n    ),\n    FollowableTopic(\n        Topic(\n            id = \"2\",\n            name = TOPIC_3_NAME,\n            shortDescription = TOPIC_SHORT_DESC,\n            longDescription = TOPIC_LONG_DESC,\n            url = TOPIC_URL,\n            imageUrl = TOPIC_IMAGE_URL,\n        ),\n        isFollowed = false,\n    ),\n)\n\nprivate val testOutputTopics = listOf(\n    FollowableTopic(\n        Topic(\n            id = \"0\",\n            name = TOPIC_1_NAME,\n            shortDescription = TOPIC_SHORT_DESC,\n            longDescription = TOPIC_LONG_DESC,\n            url = TOPIC_URL,\n            imageUrl = TOPIC_IMAGE_URL,\n        ),\n        isFollowed = true,\n    ),\n    FollowableTopic(\n        Topic(\n            id = \"1\",\n            name = TOPIC_2_NAME,\n            shortDescription = TOPIC_SHORT_DESC,\n            longDescription = TOPIC_LONG_DESC,\n            url = TOPIC_URL,\n            imageUrl = TOPIC_IMAGE_URL,\n        ),\n        isFollowed = true,\n    ),\n    FollowableTopic(\n        Topic(\n            id = \"2\",\n            name = TOPIC_3_NAME,\n            shortDescription = TOPIC_SHORT_DESC,\n            longDescription = TOPIC_LONG_DESC,\n            url = TOPIC_URL,\n            imageUrl = TOPIC_IMAGE_URL,\n        ),\n        isFollowed = false,\n    ),\n)\n\nprivate val sampleNewsResources = listOf(\n    NewsResource(\n        id = \"1\",\n        title = \"Thanks for helping us reach 1M YouTube Subscribers\",\n        content = \"Thank you everyone for following the Now in Android series and everything the \" +\n            \"Android Developers YouTube channel has to offer. During the Android Developer \" +\n            \"Summit, our YouTube channel reached 1 million subscribers! Here’s a small video to \" +\n            \"thank you all.\",\n        url = \"https://youtu.be/-fJ6poHQrjM\",\n        headerImageUrl = \"https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg\",\n        publishDate = Instant.parse(\"2021-11-09T00:00:00.000Z\"),\n        type = \"Video 📺\",\n        topics = listOf(\n            Topic(\n                id = \"0\",\n                name = \"Headlines\",\n                shortDescription = \"\",\n                longDescription = \"long description\",\n                url = \"URL\",\n                imageUrl = \"image URL\",\n            ),\n        ),\n    ),\n)\n"
  },
  {
    "path": "gradle/libs.versions.toml",
    "content": "[versions]\naccompanist = \"0.37.0\"\nandroidDesugarJdkLibs = \"2.1.4\"\n# AGP and tools should be updated together\nandroidGradlePlugin = \"9.0.0\"\nandroidTools = \"32.0.0\"\nandroidxActivity = \"1.9.3\"\nandroidxAppCompat = \"1.7.0\"\nandroidxBrowser = \"1.8.0\"\nandroidxComposeBom = \"2025.09.01\"\nandroidxComposeFoundation = \"1.8.0-alpha07\"\nandroidxComposeMaterial3Adaptive = \"1.1.0-rc01\"\nandroidxComposeMaterial3AdaptiveNavigation3 = \"1.3.0-alpha04\"\nandroidxComposeRuntimeTracing = \"1.7.6\"\nandroidxCore = \"1.15.0\"\nandroidxCoreSplashscreen = \"1.0.1\"\nandroidxDataStore = \"1.2.0\"\nandroidxEspresso = \"3.6.1\"\nandroidxHiltLifecycleViewModelCompose = \"1.3.0-alpha02\"\nandroidxLifecycle = \"2.10.0\"\nandroidxLintGradle = \"1.0.0-alpha03\"\nandroidxLifecycleViewModelNavigation3 = \"2.10.0\"\nandroidxMacroBenchmark = \"1.5.0-alpha01\"\nandroidxMetrics = \"1.0.0-beta01\"\nandroidxNavigation = \"2.8.5\"\nandroidxNavigation3 = \"1.0.0\"\nandroidxProfileinstaller = \"1.4.1\"\nandroidxSavedStateCompose = \"1.3.1\"\nandroidxTestCore = \"1.7.0-rc01\"\nandroidxTestExt = \"1.3.0-rc01\"\nandroidxTestRules = \"1.7.0-rc01\"\nandroidxTestRunner = \"1.7.0-rc01\"\nandroidxTracing = \"1.3.0-alpha02\"\nandroidxUiAutomator = \"2.3.0\"\nandroidxWindowManager = \"1.3.0\"\nandroidxWork = \"2.10.0\"\ncoil = \"2.7.0\"\ndependencyGuard = \"0.5.0\"\nfirebaseBom = \"33.7.0\"\nfirebaseCrashlyticsPlugin = \"3.0.6\"\nfirebasePerfPlugin = \"2.0.2\"\ngmsPlugin = \"4.4.4\"\ngoogleOss = \"17.1.0\"\ngoogleOssPlugin = \"0.10.9\"\nhilt = \"2.59\"\nhiltExt = \"1.2.0\"\njacoco = \"0.8.12\"\njunit4 = \"4.13.2\"\nkotlin = \"2.3.0\"\nkotlinxCoroutines = \"1.10.1\"\nkotlinxDatetime = \"0.6.1\"\nkotlinxSerializationJson = \"1.8.0\"\nksp = \"2.3.4\"\nktlint = \"1.4.0\"\nokhttp = \"4.12.0\"\nprotobuf = \"4.29.2\"\nprotobufPlugin = \"0.9.6\"\nretrofit = \"2.11.0\"\nretrofitKotlinxSerializationJson = \"1.0.0\"\nrobolectric = \"4.16\"\nroborazzi = \"1.56.0\"\nroom = \"2.8.3\"\nspotless = \"8.3.0\"\ntruth = \"1.4.4\"\nturbine = \"1.2.0\"\nuiTestJunit4 = \"1.9.0-rc01\"\n\n[bundles]\nandroidx-compose-ui-test = [\"androidx-compose-ui-test\", \"androidx-compose-ui-testManifest\"]\n\n[libraries]\naccompanist-permissions = { group = \"com.google.accompanist\", name = \"accompanist-permissions\", version.ref = \"accompanist\" }\nandroid-desugarJdkLibs = { group = \"com.android.tools\", name = \"desugar_jdk_libs\", version.ref = \"androidDesugarJdkLibs\" }\nandroidx-activity-compose = { group = \"androidx.activity\", name = \"activity-compose\", version.ref = \"androidxActivity\" }\nandroidx-appcompat = { group = \"androidx.appcompat\", name = \"appcompat\", version.ref = \"androidxAppCompat\" }\nandroidx-benchmark-macro = { group = \"androidx.benchmark\", name = \"benchmark-macro-junit4\", version.ref = \"androidxMacroBenchmark\" }\nandroidx-browser = { group = \"androidx.browser\", name = \"browser\", version.ref = \"androidxBrowser\" }\nandroidx-compose-bom = { group = \"androidx.compose\", name = \"compose-bom-alpha\", version.ref = \"androidxComposeBom\" }\nandroidx-compose-foundation = { group = \"androidx.compose.foundation\", name = \"foundation\", version.ref = \"androidxComposeFoundation\" }\nandroidx-compose-foundation-layout = { group = \"androidx.compose.foundation\", name = \"foundation-layout\" }\nandroidx-compose-material-iconsExtended = { group = \"androidx.compose.material\", name = \"material-icons-extended\" }\nandroidx-compose-material3 = { group = \"androidx.compose.material3\", name = \"material3\" }\nandroidx-compose-material3-navigationSuite = { group = \"androidx.compose.material3\", name = \"material3-adaptive-navigation-suite\" }\nandroidx-compose-material3-adaptive = { group = \"androidx.compose.material3.adaptive\", name = \"adaptive\", version.ref = \"androidxComposeMaterial3Adaptive\" }\nandroidx-compose-material3-adaptive-layout = { group = \"androidx.compose.material3.adaptive\", name = \"adaptive-layout\", version.ref = \"androidxComposeMaterial3Adaptive\" }\nandroidx-compose-material3-adaptive-navigation = { group = \"androidx.compose.material3.adaptive\", name = \"adaptive-navigation\", version.ref = \"androidxComposeMaterial3Adaptive\" }\nandroidx-compose-material3-adaptive-navigation3 = { group = \"androidx.compose.material3.adaptive\", name = \"adaptive-navigation3\",version.ref=\"androidxComposeMaterial3AdaptiveNavigation3\" }\nandroidx-compose-material3-windowSizeClass = { group = \"androidx.compose.material3\", name = \"material3-window-size-class\" }\nandroidx-compose-runtime = { group = \"androidx.compose.runtime\", name = \"runtime\" }\nandroidx-compose-runtime-tracing = { group = \"androidx.compose.runtime\", name = \"runtime-tracing\", version.ref = \"androidxComposeRuntimeTracing\" }\nandroidx-compose-ui-test = { group = \"androidx.compose.ui\", name = \"ui-test-junit4\" }\nandroidx-compose-ui-testManifest = { group = \"androidx.compose.ui\", name = \"ui-test-manifest\" }\nandroidx-compose-ui-tooling = { group = \"androidx.compose.ui\", name = \"ui-tooling\" }\nandroidx-compose-ui-tooling-preview = { group = \"androidx.compose.ui\", name = \"ui-tooling-preview\" }\nandroidx-compose-ui-util = { group = \"androidx.compose.ui\", name = \"ui-util\" }\nandroidx-core-ktx = { group = \"androidx.core\", name = \"core-ktx\", version.ref = \"androidxCore\" }\nandroidx-core-splashscreen = { group = \"androidx.core\", name = \"core-splashscreen\", version.ref = \"androidxCoreSplashscreen\" }\nandroidx-dataStore = { group = \"androidx.datastore\", name = \"datastore\", version.ref = \"androidxDataStore\" }\nandroidx-dataStore-core = { group = \"androidx.datastore\", name = \"datastore-core\", version.ref = \"androidxDataStore\" }\nandroidx-hilt-lifecycle-viewModelCompose = { group = \"androidx.hilt\", name = \"hilt-lifecycle-viewmodel-compose\", version.ref = \"androidxHiltLifecycleViewModelCompose\" }\nandroidx-lifecycle-runtimeCompose = { group = \"androidx.lifecycle\", name = \"lifecycle-runtime-compose\", version.ref = \"androidxLifecycle\" }\nandroidx-lifecycle-runtimeTesting = { group = \"androidx.lifecycle\", name = \"lifecycle-runtime-testing\", version.ref = \"androidxLifecycle\" }\nandroidx-lifecycle-viewModelCompose = { group = \"androidx.lifecycle\", name = \"lifecycle-viewmodel-compose\", version.ref = \"androidxLifecycle\" }\nandroidx-lifecycle-viewModel-testing = { group = \"androidx.lifecycle\", name = \"lifecycle-viewmodel-testing\", version.ref = \"androidxLifecycle\" }\nandroidx-lifecycle-viewModel-navigation3 = { group = \"androidx.lifecycle\", name = \"lifecycle-viewmodel-navigation3\", version.ref = \"androidxLifecycleViewModelNavigation3\" }\nandroidx-lint-gradle = { group = \"androidx.lint\", name = \"lint-gradle\", version.ref = \"androidxLintGradle\" }\nandroidx-metrics = { group = \"androidx.metrics\", name = \"metrics-performance\", version.ref = \"androidxMetrics\" }\nandroidx-navigation-testing = { group = \"androidx.navigation\", name = \"navigation-testing\", version.ref = \"androidxNavigation\" }\nandroidx-navigation3-runtime = { group = \"androidx.navigation3\", name = \"navigation3-runtime\", version.ref = \"androidxNavigation3\" }\nandroidx-navigation3-ui = { group = \"androidx.navigation3\", name = \"navigation3-ui\", version.ref = \"androidxNavigation3\" }\nandroidx-savedstate-compose = { group = \"androidx.savedstate\", name = \"savedstate-compose\", version.ref = \"androidxSavedStateCompose\" }\nandroidx-profileinstaller = { group = \"androidx.profileinstaller\", name = \"profileinstaller\", version.ref = \"androidxProfileinstaller\" }\nandroidx-test-core = { group = \"androidx.test\", name = \"core\", version.ref = \"androidxTestCore\" }\nandroidx-test-espresso-core = { group = \"androidx.test.espresso\", name = \"espresso-core\", version.ref = \"androidxEspresso\" }\nandroidx-test-ext = { group = \"androidx.test.ext\", name = \"junit-ktx\", version.ref = \"androidxTestExt\" }\nandroidx-test-rules = { group = \"androidx.test\", name = \"rules\", version.ref = \"androidxTestRules\" }\nandroidx-test-runner = { group = \"androidx.test\", name = \"runner\", version.ref = \"androidxTestRunner\" }\nandroidx-test-uiautomator = { group = \"androidx.test.uiautomator\", name = \"uiautomator\", version.ref = \"androidxUiAutomator\" }\nandroidx-tracing-ktx = { group = \"androidx.tracing\", name = \"tracing-ktx\", version.ref = \"androidxTracing\" }\nandroidx-window-core = { group = \"androidx.window\", name = \"window-core\", version.ref = \"androidxWindowManager\" }\nandroidx-work-ktx = { group = \"androidx.work\", name = \"work-runtime-ktx\", version.ref = \"androidxWork\" }\nandroidx-work-testing = { group = \"androidx.work\", name = \"work-testing\", version.ref = \"androidxWork\" }\ncoil-kt = { group = \"io.coil-kt\", name = \"coil\", version.ref = \"coil\" }\ncoil-kt-compose = { group = \"io.coil-kt\", name = \"coil-compose\", version.ref = \"coil\" }\ncoil-kt-svg = { group = \"io.coil-kt\", name = \"coil-svg\", version.ref = \"coil\" }\nfirebase-analytics = { group = \"com.google.firebase\", name = \"firebase-analytics\" }\nfirebase-bom = { group = \"com.google.firebase\", name = \"firebase-bom\", version.ref = \"firebaseBom\" }\nfirebase-cloud-messaging = { group = \"com.google.firebase\", name = \"firebase-messaging\" }\nfirebase-crashlytics = { group = \"com.google.firebase\", name = \"firebase-crashlytics\" }\nfirebase-performance = { group = \"com.google.firebase\", name = \"firebase-perf\" }\ngoogle-oss-licenses = { group = \"com.google.android.gms\", name = \"play-services-oss-licenses\", version.ref = \"googleOss\" }\nhilt-android = { group = \"com.google.dagger\", name = \"hilt-android\", version.ref = \"hilt\" }\nhilt-android-testing = { group = \"com.google.dagger\", name = \"hilt-android-testing\", version.ref = \"hilt\" }\nhilt-compiler = { group = \"com.google.dagger\", name = \"hilt-compiler\", version.ref = \"hilt\" }\nhilt-core = { group = \"com.google.dagger\", name = \"hilt-core\", version.ref = \"hilt\" }\nhilt-ext-compiler = { group = \"androidx.hilt\", name = \"hilt-compiler\", version.ref = \"hiltExt\" }\nhilt-ext-work = { group = \"androidx.hilt\", name = \"hilt-work\", version.ref = \"hiltExt\" }\njavax-inject = { module = \"javax.inject:javax.inject\", version = \"1\" }\njunit = { module = \"junit:junit\", version.ref = \"junit4\" }\nkotlin-metadata = { module = \"org.jetbrains.kotlin:kotlin-metadata-jvm\", version.ref = \"kotlin\" }\nkotlin-stdlib = { group = \"org.jetbrains.kotlin\", name = \"kotlin-stdlib-jdk8\", version.ref = \"kotlin\" }\nkotlin-test = { group = \"org.jetbrains.kotlin\", name = \"kotlin-test\", version.ref = \"kotlin\" }\nkotlinx-coroutines-core = { group = \"org.jetbrains.kotlinx\", name = \"kotlinx-coroutines-core\", version.ref = \"kotlinxCoroutines\" }\nkotlinx-coroutines-android = { group = \"org.jetbrains.kotlinx\", name = \"kotlinx-coroutines-android\", version.ref = \"kotlinxCoroutines\" }\nkotlinx-coroutines-guava = { group = \"org.jetbrains.kotlinx\", name = \"kotlinx-coroutines-guava\", version.ref = \"kotlinxCoroutines\" }\nkotlinx-coroutines-test = { group = \"org.jetbrains.kotlinx\", name = \"kotlinx-coroutines-test\", version.ref = \"kotlinxCoroutines\" }\nkotlinx-datetime = { group = \"org.jetbrains.kotlinx\", name = \"kotlinx-datetime\", version.ref = \"kotlinxDatetime\" }\nkotlinx-serialization-json = { group = \"org.jetbrains.kotlinx\", name = \"kotlinx-serialization-json\", version.ref = \"kotlinxSerializationJson\" }\nlint-api = { group = \"com.android.tools.lint\", name = \"lint-api\", version.ref = \"androidTools\" }\nlint-checks = { group = \"com.android.tools.lint\", name = \"lint-checks\", version.ref = \"androidTools\" }\nlint-tests = { group = \"com.android.tools.lint\", name = \"lint-tests\", version.ref = \"androidTools\" }\nokhttp-logging = { group = \"com.squareup.okhttp3\", name = \"logging-interceptor\", version.ref = \"okhttp\" }\nprotobuf-kotlin-lite = { group = \"com.google.protobuf\", name = \"protobuf-kotlin-lite\", version.ref = \"protobuf\" }\nprotobuf-protoc = { group = \"com.google.protobuf\", name = \"protoc\", version.ref = \"protobuf\" }\nretrofit-core = { group = \"com.squareup.retrofit2\", name = \"retrofit\", version.ref = \"retrofit\" }\nretrofit-kotlin-serialization = { group = \"com.squareup.retrofit2\", name = \"converter-kotlinx-serialization\", version.ref = \"retrofit\" }\nrobolectric = { group = \"org.robolectric\", name = \"robolectric\", version.ref = \"robolectric\" }\nroborazzi = { group = \"io.github.takahirom.roborazzi\", name = \"roborazzi\", version.ref = \"roborazzi\" }\nroborazzi-accessibility-check = { group = \"io.github.takahirom.roborazzi\", name = \"roborazzi-accessibility-check\", version.ref = \"roborazzi\" }\nroom-compiler = { group = \"androidx.room\", name = \"room-compiler\", version.ref = \"room\" }\nroom-ktx = { group = \"androidx.room\", name = \"room-ktx\", version.ref = \"room\" }\nroom-runtime = { group = \"androidx.room\", name = \"room-runtime\", version.ref = \"room\" }\ntruth = { group = \"com.google.truth\", name = \"truth\", version.ref = \"truth\" }\nturbine = { group = \"app.cash.turbine\", name = \"turbine\", version.ref = \"turbine\" }\n\n# Dependencies of the included build-logic\nandroid-gradlePlugin = { group = \"com.android.tools.build\", name = \"gradle-api\", version.ref = \"androidGradlePlugin\" }\nandroid-tools-common = { group = \"com.android.tools\", name = \"common\", version.ref = \"androidTools\" }\ncompose-gradlePlugin = { module = \"org.jetbrains.kotlin:compose-compiler-gradle-plugin\", version.ref = \"kotlin\" }\nfirebase-crashlytics-gradlePlugin = { group = \"com.google.firebase\", name = \"firebase-crashlytics-gradle\", version.ref = \"firebaseCrashlyticsPlugin\" }\nfirebase-performance-gradlePlugin = { group = \"com.google.firebase\", name = \"perf-plugin\", version.ref = \"firebasePerfPlugin\" }\nkotlin-gradlePlugin = { group = \"org.jetbrains.kotlin\", name = \"kotlin-gradle-plugin\", version.ref = \"kotlin\" }\nksp-gradlePlugin = { group = \"com.google.devtools.ksp\", name = \"com.google.devtools.ksp.gradle.plugin\", version.ref = \"ksp\" }\nroom-gradlePlugin = { group = \"androidx.room\", name = \"room-gradle-plugin\", version.ref = \"room\" }\nandroidx-compose-ui-test-junit4 = { group = \"androidx.compose.ui\", name = \"ui-test-junit4\", version.ref = \"uiTestJunit4\" }\nspotless-gradlePlugin = { group = \"com.diffplug.spotless\", name = \"spotless-plugin-gradle\", version.ref = \"spotless\" }\n\n[plugins]\nandroid-application = { id = \"com.android.application\", version.ref = \"androidGradlePlugin\" }\nandroid-library = { id = \"com.android.library\", version.ref = \"androidGradlePlugin\" }\nandroid-lint = { id = \"com.android.lint\", version.ref = \"androidGradlePlugin\" }\nandroid-test = { id = \"com.android.test\", version.ref = \"androidGradlePlugin\" }\nbaselineprofile = { id = \"androidx.baselineprofile\", version.ref = \"androidxMacroBenchmark\"}\ncompose = { id = \"org.jetbrains.kotlin.plugin.compose\", version.ref = \"kotlin\" }\ndependencyGuard = { id = \"com.dropbox.dependency-guard\", version.ref = \"dependencyGuard\" }\nfirebase-crashlytics = { id = \"com.google.firebase.crashlytics\", version.ref = \"firebaseCrashlyticsPlugin\" }\nfirebase-perf = { id = \"com.google.firebase.firebase-perf\", version.ref = \"firebasePerfPlugin\" }\ngms = { id = \"com.google.gms.google-services\", version.ref = \"gmsPlugin\" }\nhilt = { id = \"com.google.dagger.hilt.android\", version.ref = \"hilt\" }\nkotlin-jvm = { id = \"org.jetbrains.kotlin.jvm\", version.ref = \"kotlin\" }\nkotlin-serialization = { id = \"org.jetbrains.kotlin.plugin.serialization\", version.ref = \"kotlin\" }\nksp = { id = \"com.google.devtools.ksp\", version.ref = \"ksp\" }\ngoogle-osslicenses = { id = \"com.google.android.gms.oss-licenses-plugin\", version.ref = \"googleOssPlugin\" }\nprotobuf = { id = \"com.google.protobuf\", version.ref = \"protobufPlugin\" }\nroborazzi = { id = \"io.github.takahirom.roborazzi\", version.ref = \"roborazzi\" }\nroom = { id = \"androidx.room\", version.ref = \"room\" }\nspotless = { id = \"com.diffplug.spotless\", version.ref = \"spotless\" }\n\n# Plugins defined by this project\nnowinandroid-android-application = { id = \"nowinandroid.android.application\" }\nnowinandroid-android-application-compose = { id = \"nowinandroid.android.application.compose\" }\nnowinandroid-android-application-firebase = { id = \"nowinandroid.android.application.firebase\" }\nnowinandroid-android-application-flavors = { id = \"nowinandroid.android.application.flavors\" }\nnowinandroid-android-application-jacoco = { id = \"nowinandroid.android.application.jacoco\" }\nnowinandroid-android-feature-impl = { id = \"nowinandroid.android.feature.impl\" }\nnowinandroid-android-feature-api = { id = \"nowinandroid.android.feature.api\" }\nnowinandroid-android-library = { id = \"nowinandroid.android.library\" }\nnowinandroid-android-library-compose = { id = \"nowinandroid.android.library.compose\" }\nnowinandroid-android-library-jacoco = { id = \"nowinandroid.android.library.jacoco\" }\nnowinandroid-android-lint = { id = \"nowinandroid.android.lint\" }\nnowinandroid-android-room = { id = \"nowinandroid.android.room\" }\nnowinandroid-android-test = { id = \"nowinandroid.android.test\" }\nnowinandroid-hilt = { id = \"nowinandroid.hilt\" }\nnowinandroid-jvm-library = { id = \"nowinandroid.jvm.library\" }\nnowinandroid-root = { id = \"nowinandroid.root\" }\n"
  },
  {
    "path": "gradle/wrapper/gradle-wrapper.properties",
    "content": "distributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\ndistributionSha256Sum=60ea723356d81263e8002fec0fcf9e2b0eee0c0850c7a3d7ab0a63f2ccc601f3\ndistributionUrl=https\\://services.gradle.org/distributions/gradle-9.4.0-bin.zip\nnetworkTimeout=10000\nvalidateDistributionUrl=true\nzipStoreBase=GRADLE_USER_HOME\nzipStorePath=wrapper/dists\n"
  },
  {
    "path": "gradle.properties",
    "content": "# Project-wide Gradle settings.\n# IDE (e.g. Android Studio) users:\n# Gradle settings configured through the IDE *will override*\n# any settings specified in this file.\n# For more details on how to configure your build environment visit\n# http://www.gradle.org/docs/current/userguide/build_environment.html\n\n# Specifies the JVM arguments used for the daemon process.\n# The setting is particularly useful for tweaking memory settings.\n# Ensure important default jvmargs aren't overwritten. See https://github.com/gradle/gradle/issues/19750\n#\n# For more information about how Gradle memory options were chosen:\n# - Metaspace See https://www.jasonpearson.dev/metaspace-in-jvm-builds/\n# - SoftRefLRUPolicyMSPerMB would default to 1000 which with a 4gb heap translates to ~51 minutes.\n#   A value of 1 means ~4 seconds before SoftRefs can be collected, which means its realistic to\n#   collect them as needed during a build that should take seconds to minutes.\n# - CodeCache normally defaults to a very small size. Increasing it from platform defaults of 32-48m\n#   because of how many classes can be loaded into memory and then cached as native compiled code\n#   for a small speed boost.\norg.gradle.jvmargs=-Dfile.encoding=UTF-8 -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=1 -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError -Xmx4g -Xms4g\n\n# For more information about how Kotlin Daemon memory options were chosen:\n# - Kotlin JVM args only inherit Xmx, ReservedCodeCache, and MaxMetaspace. Since we are specifying\n#   other args we need to specify all of them here.\n# - We're using the Kotlin Gradle Plugin's default value for ReservedCodeCacheSize, if we do not then\n#   the Gradle JVM arg value for ReservedCodeCacheSize will be used.\nkotlin.daemon.jvmargs=-Dfile.encoding=UTF-8 -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=1 -XX:ReservedCodeCacheSize=320m -XX:+HeapDumpOnOutOfMemoryError -Xmx4g -Xms4g\n\n# When configured, Gradle will run in incubating parallel mode.\n# This option should only be used with decoupled projects. More details, visit\n# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects\norg.gradle.parallel=true\n\n# Not encouraged by Gradle and can produce weird results. Wait for isolated projects instead.\norg.gradle.configureondemand=false\n\n# Enable caching between builds.\norg.gradle.caching=true\n\n# Enable configuration caching between builds.\norg.gradle.configuration-cache=true\norg.gradle.configuration-cache.parallel=true\n# This option is set because of https://github.com/google/play-services-plugins/issues/246\n# to generate the Configuration Cache regardless of incompatible tasks.\n# See https://github.com/android/nowinandroid/issues/1022 before using it.\norg.gradle.configuration-cache.problems=warn\n\n# AndroidX package structure to make it clearer which packages are bundled with the\n# Android operating system, and which are packaged with your app\"s APK\n# https://developer.android.com/topic/libraries/support-library/androidx-rn\nandroid.useAndroidX=true\n# Kotlin code style for this project: \"official\" or \"obsolete\":\nkotlin.code.style=official\n\n# Disable build features that are enabled by default,\n# https://developer.android.com/build/releases/gradle-plugin#default-changes\nandroid.defaults.buildfeatures.resvalues=false\nandroid.defaults.buildfeatures.shaders=false\n\n# Run Roborazzi screenshot tests with the local tests\nroborazzi.test.verify=true\n\n# Prevent uninstall app after instrumented tests\n# https://issuetracker.google.com/issues/295039976\nandroid.injected.androidTest.leaveApksInstalledAfterRun=true\n\nksp.project.isolation.enabled=true"
  },
  {
    "path": "gradlew",
    "content": "#!/bin/sh\n\n#\n# Copyright © 2015 the original authors.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      https://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n# SPDX-License-Identifier: Apache-2.0\n#\n\n##############################################################################\n#\n#   Gradle start up script for POSIX generated by Gradle.\n#\n#   Important for running:\n#\n#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is\n#       noncompliant, but you have some other compliant shell such as ksh or\n#       bash, then to run this script, type that shell name before the whole\n#       command line, like:\n#\n#           ksh Gradle\n#\n#       Busybox and similar reduced shells will NOT work, because this script\n#       requires all of these POSIX shell features:\n#         * functions;\n#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,\n#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;\n#         * compound commands having a testable exit status, especially «case»;\n#         * various built-in commands including «command», «set», and «ulimit».\n#\n#   Important for patching:\n#\n#   (2) This script targets any POSIX shell, so it avoids extensions provided\n#       by Bash, Ksh, etc; in particular arrays are avoided.\n#\n#       The \"traditional\" practice of packing multiple parameters into a\n#       space-separated string is a well documented source of bugs and security\n#       problems, so this is (mostly) avoided, by progressively accumulating\n#       options in \"$@\", and eventually passing that to Java.\n#\n#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,\n#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;\n#       see the in-line comments for details.\n#\n#       There are tweaks for specific operating systems such as AIX, CygWin,\n#       Darwin, MinGW, and NonStop.\n#\n#   (3) This script is generated from the Groovy template\n#       https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt\n#       within the Gradle project.\n#\n#       You can find Gradle at https://github.com/gradle/gradle/.\n#\n##############################################################################\n\n# Attempt to set APP_HOME\n\n# Resolve links: $0 may be a link\napp_path=$0\n\n# Need this for daisy-chained symlinks.\nwhile\n    APP_HOME=${app_path%\"${app_path##*/}\"}  # leaves a trailing /; empty if no leading path\n    [ -h \"$app_path\" ]\ndo\n    ls=$( ls -ld \"$app_path\" )\n    link=${ls#*' -> '}\n    case $link in             #(\n      /*)   app_path=$link ;; #(\n      *)    app_path=$APP_HOME$link ;;\n    esac\ndone\n\n# This is normally unused\n# shellcheck disable=SC2034\nAPP_BASE_NAME=${0##*/}\n# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)\nAPP_HOME=$( cd -P \"${APP_HOME:-./}\" > /dev/null && printf '%s\\n' \"$PWD\" ) || exit\n\n# Use the maximum available, or set MAX_FD != -1 to use that value.\nMAX_FD=maximum\n\nwarn () {\n    echo \"$*\"\n} >&2\n\ndie () {\n    echo\n    echo \"$*\"\n    echo\n    exit 1\n} >&2\n\n# OS specific support (must be 'true' or 'false').\ncygwin=false\nmsys=false\ndarwin=false\nnonstop=false\ncase \"$( uname )\" in                #(\n  CYGWIN* )         cygwin=true  ;; #(\n  Darwin* )         darwin=true  ;; #(\n  MSYS* | MINGW* )  msys=true    ;; #(\n  NONSTOP* )        nonstop=true ;;\nesac\n\n\n\n# Determine the Java command to use to start the JVM.\nif [ -n \"$JAVA_HOME\" ] ; then\n    if [ -x \"$JAVA_HOME/jre/sh/java\" ] ; then\n        # IBM's JDK on AIX uses strange locations for the executables\n        JAVACMD=$JAVA_HOME/jre/sh/java\n    else\n        JAVACMD=$JAVA_HOME/bin/java\n    fi\n    if [ ! -x \"$JAVACMD\" ] ; then\n        die \"ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\n    fi\nelse\n    JAVACMD=java\n    if ! command -v java >/dev/null 2>&1\n    then\n        die \"ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\n    fi\nfi\n\n# Increase the maximum file descriptors if we can.\nif ! \"$cygwin\" && ! \"$darwin\" && ! \"$nonstop\" ; then\n    case $MAX_FD in #(\n      max*)\n        # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.\n        # shellcheck disable=SC2039,SC3045\n        MAX_FD=$( ulimit -H -n ) ||\n            warn \"Could not query maximum file descriptor limit\"\n    esac\n    case $MAX_FD in  #(\n      '' | soft) :;; #(\n      *)\n        # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.\n        # shellcheck disable=SC2039,SC3045\n        ulimit -n \"$MAX_FD\" ||\n            warn \"Could not set maximum file descriptor limit to $MAX_FD\"\n    esac\nfi\n\n# Collect all arguments for the java command, stacking in reverse order:\n#   * args from the command line\n#   * the main class name\n#   * -classpath\n#   * -D...appname settings\n#   * --module-path (only if needed)\n#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.\n\n# For Cygwin or MSYS, switch paths to Windows format before running java\nif \"$cygwin\" || \"$msys\" ; then\n    APP_HOME=$( cygpath --path --mixed \"$APP_HOME\" )\n\n    JAVACMD=$( cygpath --unix \"$JAVACMD\" )\n\n    # Now convert the arguments - kludge to limit ourselves to /bin/sh\n    for arg do\n        if\n            case $arg in                                #(\n              -*)   false ;;                            # don't mess with options #(\n              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath\n                    [ -e \"$t\" ] ;;                      #(\n              *)    false ;;\n            esac\n        then\n            arg=$( cygpath --path --ignore --mixed \"$arg\" )\n        fi\n        # Roll the args list around exactly as many times as the number of\n        # args, so each arg winds up back in the position where it started, but\n        # possibly modified.\n        #\n        # NB: a `for` loop captures its iteration list before it begins, so\n        # changing the positional parameters here affects neither the number of\n        # iterations, nor the values presented in `arg`.\n        shift                   # remove old arg\n        set -- \"$@\" \"$arg\"      # push replacement arg\n    done\nfi\n\n\n# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.\nDEFAULT_JVM_OPTS='\"-Xmx64m\" \"-Xms64m\"'\n\n# Collect all arguments for the java command:\n#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,\n#     and any embedded shellness will be escaped.\n#   * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be\n#     treated as '${Hostname}' itself on the command line.\n\nset -- \\\n        \"-Dorg.gradle.appname=$APP_BASE_NAME\" \\\n        -jar \"$APP_HOME/gradle/wrapper/gradle-wrapper.jar\" \\\n        \"$@\"\n\n# Stop when \"xargs\" is not available.\nif ! command -v xargs >/dev/null 2>&1\nthen\n    die \"xargs is not available\"\nfi\n\n# Use \"xargs\" to parse quoted args.\n#\n# With -n1 it outputs one arg per line, with the quotes and backslashes removed.\n#\n# In Bash we could simply go:\n#\n#   readarray ARGS < <( xargs -n1 <<<\"$var\" ) &&\n#   set -- \"${ARGS[@]}\" \"$@\"\n#\n# but POSIX shell has neither arrays nor command substitution, so instead we\n# post-process each arg (as a line of input to sed) to backslash-escape any\n# character that might be a shell metacharacter, then use eval to reverse\n# that process (while maintaining the separation between arguments), and wrap\n# the whole thing up as a single \"set\" statement.\n#\n# This will of course break if any of these variables contains a newline or\n# an unmatched quote.\n#\n\neval \"set -- $(\n        printf '%s\\n' \"$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS\" |\n        xargs -n1 |\n        sed ' s~[^-[:alnum:]+,./:=@_]~\\\\&~g; ' |\n        tr '\\n' ' '\n    )\" '\"$@\"'\n\nexec \"$JAVACMD\" \"$@\"\n"
  },
  {
    "path": "gradlew.bat",
    "content": "@rem\r\n@rem Copyright 2015 the original author or authors.\r\n@rem\r\n@rem Licensed under the Apache License, Version 2.0 (the \"License\");\r\n@rem you may not use this file except in compliance with the License.\r\n@rem You may obtain a copy of the License at\r\n@rem\r\n@rem      https://www.apache.org/licenses/LICENSE-2.0\r\n@rem\r\n@rem Unless required by applicable law or agreed to in writing, software\r\n@rem distributed under the License is distributed on an \"AS IS\" BASIS,\r\n@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r\n@rem See the License for the specific language governing permissions and\r\n@rem limitations under the License.\r\n@rem\r\n@rem SPDX-License-Identifier: Apache-2.0\r\n@rem\r\n\r\n@if \"%DEBUG%\"==\"\" @echo off\r\n@rem ##########################################################################\r\n@rem\r\n@rem  Gradle startup script for Windows\r\n@rem\r\n@rem ##########################################################################\r\n\r\n@rem Set local scope for the variables with windows NT shell\r\nif \"%OS%\"==\"Windows_NT\" setlocal\r\n\r\nset DIRNAME=%~dp0\r\nif \"%DIRNAME%\"==\"\" set DIRNAME=.\r\n@rem This is normally unused\r\nset APP_BASE_NAME=%~n0\r\nset APP_HOME=%DIRNAME%\r\n\r\n@rem Resolve any \".\" and \"..\" in APP_HOME to make it shorter.\r\nfor %%i in (\"%APP_HOME%\") do set APP_HOME=%%~fi\r\n\r\n@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.\r\nset DEFAULT_JVM_OPTS=\"-Xmx64m\" \"-Xms64m\"\r\n\r\n@rem Find java.exe\r\nif defined JAVA_HOME goto findJavaFromJavaHome\r\n\r\nset JAVA_EXE=java.exe\r\n%JAVA_EXE% -version >NUL 2>&1\r\nif %ERRORLEVEL% equ 0 goto execute\r\n\r\necho. 1>&2\r\necho ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2\r\necho. 1>&2\r\necho Please set the JAVA_HOME variable in your environment to match the 1>&2\r\necho location of your Java installation. 1>&2\r\n\r\ngoto fail\r\n\r\n:findJavaFromJavaHome\r\nset JAVA_HOME=%JAVA_HOME:\"=%\r\nset JAVA_EXE=%JAVA_HOME%/bin/java.exe\r\n\r\nif exist \"%JAVA_EXE%\" goto execute\r\n\r\necho. 1>&2\r\necho ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2\r\necho. 1>&2\r\necho Please set the JAVA_HOME variable in your environment to match the 1>&2\r\necho location of your Java installation. 1>&2\r\n\r\ngoto fail\r\n\r\n:execute\r\n@rem Setup the command line\r\n\r\n\r\n\r\n@rem Execute Gradle\r\n\"%JAVA_EXE%\" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% \"-Dorg.gradle.appname=%APP_BASE_NAME%\" -jar \"%APP_HOME%\\gradle\\wrapper\\gradle-wrapper.jar\" %*\r\n\r\n:end\r\n@rem End local scope for the variables with windows NT shell\r\nif %ERRORLEVEL% equ 0 goto mainEnd\r\n\r\n:fail\r\nrem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of\r\nrem the _cmd.exe /c_ return code!\r\nset EXIT_CODE=%ERRORLEVEL%\r\nif %EXIT_CODE% equ 0 set EXIT_CODE=1\r\nif not \"\"==\"%GRADLE_EXIT_CONSOLE%\" exit %EXIT_CODE%\r\nexit /b %EXIT_CODE%\r\n\r\n:mainEnd\r\nif \"%OS%\"==\"Windows_NT\" endlocal\r\n\r\n:omega\r\n"
  },
  {
    "path": "kokoro/build.sh",
    "content": "#!/bin/bash\n\n# Copyright 2021 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     https://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# Fail on any error.\nset -e\n# Display commands to stderr.\nset -x\n\ndeviceIds=${1:-'Nexus5,Pixel2,Pixel3,Nexus9'}\nosVersionIds=${2:-'27,30'}\n\nGRADLE_FLAGS=()\nif [[ -n \"$GRADLE_DEBUG\" ]]; then\n  GRADLE_FLAGS=( --debug --stacktrace )\nfi\n\n# Install the build tools and accept all licenses\nexport ANDROID_HOME=/opt/android-sdk/current\necho \"Installing build-tools...\"\necho y | ${ANDROID_HOME}/tools/bin/sdkmanager \"build-tools;30.0.3\" > /dev/null\necho y | ${ANDROID_HOME}/tools/bin/sdkmanager --licenses\n\ncd $KOKORO_ARTIFACTS_DIR/git/nowinandroid\n\n# The build needs Java 17, set it as the default Java version.\nsudo apt-get update\nsudo apt-get install -y openjdk-17-jdk\nsudo update-alternatives --set java /usr/lib/jvm/java-17-openjdk-amd64/bin/java\njava -version\n\n# Also clear JAVA_HOME variable so java -version is used instead\nexport JAVA_HOME=\n\n./gradlew \"${GRADLE_FLAGS[@]}\" build\n\n# For Firebase Test Lab\n./gradlew assembleAndroidTest\n./gradlew assembleDebug\n\nMAX_RETRY=3\nrun_firebase_test_lab() {\n  ## Retry can be done by passing the --num-flaky-test-attempts to gcloud, but gcloud SDK in the\n  ## kokoro server doesn't support it yet.\n\n  ## FTL requires a normal apk, even though we don't need or have any for library modules\n  ## For now, just pass in the main app apk\n\n  set +e # To not exit on an error to retry flaky tests\n  local counter=0\n  local result=1\n  local testApk=$1\n  while [ $result != 0 -a $counter -lt $MAX_RETRY ]; do\n    gcloud firebase test android run \\\n      --type instrumentation \\\n      --app  \"app/build/outputs/apk/demo/debug/app-demo-debug.apk\" \\\n      --test \"$testApk\" \\\n      --device-ids $deviceIds \\\n      --os-version-ids $osVersionIds \\\n      --locales en \\\n      --timeout 300\n    result=$? ;\n    let counter=counter+1\n  done\n  return $result\n}\n\n\n# All modules with androidTest to run tests on.\ntestApks=($(./gradlew -q demoDebugPrintTestApk))\n\n# Run all modules in parallel with Firebase Test Lab, and fail if any fail\npids=\"\"\nresult=0\n\nfor testApk in ${testApks[@]}; do\n  run_firebase_test_lab $testApk &\n  pids=\"$pids $!\"\ndone\n\nfor pid in ${pids[@]}; do\n  wait $pid || let \"result=1\"\ndone\n\nexit $result\n"
  },
  {
    "path": "kokoro/continuous.cfg",
    "content": "# Location of the bash script.\nbuild_file: \"nowinandroid/kokoro/build.sh\""
  },
  {
    "path": "kokoro/nightly.cfg",
    "content": "# Location of the bash script.\nbuild_file: \"nowinandroid/kokoro/nightly.sh\"\n"
  },
  {
    "path": "kokoro/nightly.sh",
    "content": "#!/bin/bash\n\n# Copyright 2021 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     https://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n\n# Fail on any error.\nset -e\n# Display commands to stderr.\nset -x\n\n# Run the normal build, but replace the default virtual devices with physical ones.\n# walleye     | Pixel 2       | API 27 | Phone\n# gts4lltevzw | Galaxy Tab S4 | API 28 | Tablet\n# a10         | Samsung A10   | API 29 | Phone\n# redfin      | Pixel 5e      | API 30 | Phone\n# oriole      | Pixel 6       | API 31 | Phone\nbash $KOKORO_ARTIFACTS_DIR/git/nowinandroid/kokoro/build.sh \"walleye,gts4lltevzw,a10,redfin,oriole\" \"27,28,29,30,31\"\n\nexit $?\n"
  },
  {
    "path": "kokoro/presubmit.cfg",
    "content": "# Location of the bash script.\nbuild_file: \"nowinandroid/kokoro/build.sh\""
  },
  {
    "path": "lint/.gitignore",
    "content": "/build"
  },
  {
    "path": "lint/README.md",
    "content": "# `:lint`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  :lint[lint]:::unknown\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "lint/build.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport org.jetbrains.kotlin.gradle.dsl.JvmTarget\n\nplugins {\n    `java-library`\n    kotlin(\"jvm\")\n    alias(libs.plugins.nowinandroid.android.lint)\n}\n\njava {\n    // Up to Java 11 APIs are available through desugaring\n    // https://developer.android.com/studio/write/java11-minimal-support-table\n    sourceCompatibility = JavaVersion.VERSION_11\n    targetCompatibility = JavaVersion.VERSION_11\n}\n\nkotlin {\n    compilerOptions {\n        jvmTarget = JvmTarget.JVM_11\n    }\n}\n\ndependencies {\n    compileOnly(libs.kotlin.stdlib)\n    compileOnly(libs.lint.api)\n    testImplementation(libs.kotlin.test)\n    testImplementation(libs.lint.checks)\n    testImplementation(libs.lint.tests)\n}\n"
  },
  {
    "path": "lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/NiaIssueRegistry.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.lint\n\nimport com.android.tools.lint.client.api.IssueRegistry\nimport com.android.tools.lint.client.api.Vendor\nimport com.android.tools.lint.detector.api.CURRENT_API\nimport com.google.samples.apps.nowinandroid.lint.designsystem.DesignSystemDetector\n\nclass NiaIssueRegistry : IssueRegistry() {\n\n    override val issues = listOf(\n        DesignSystemDetector.ISSUE,\n        TestMethodNameDetector.FORMAT,\n        TestMethodNameDetector.PREFIX,\n    )\n\n    override val api: Int = CURRENT_API\n\n    override val minApi: Int = 12\n\n    override val vendor: Vendor = Vendor(\n        vendorName = \"Now in Android\",\n        feedbackUrl = \"https://github.com/android/nowinandroid/issues\",\n        contact = \"https://github.com/android/nowinandroid\",\n    )\n}\n"
  },
  {
    "path": "lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/TestMethodNameDetector.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.lint\n\nimport com.android.tools.lint.detector.api.AnnotationInfo\nimport com.android.tools.lint.detector.api.AnnotationUsageInfo\nimport com.android.tools.lint.detector.api.Category.Companion.TESTING\nimport com.android.tools.lint.detector.api.Detector\nimport com.android.tools.lint.detector.api.Implementation\nimport com.android.tools.lint.detector.api.Issue\nimport com.android.tools.lint.detector.api.JavaContext\nimport com.android.tools.lint.detector.api.LintFix\nimport com.android.tools.lint.detector.api.Scope.JAVA_FILE\nimport com.android.tools.lint.detector.api.Scope.TEST_SOURCES\nimport com.android.tools.lint.detector.api.Severity.WARNING\nimport com.android.tools.lint.detector.api.SourceCodeScanner\nimport com.android.tools.lint.detector.api.TextFormat.RAW\nimport com.intellij.psi.PsiMethod\nimport org.jetbrains.uast.UElement\nimport java.util.EnumSet\nimport kotlin.io.path.Path\n\n/**\n * A detector that checks for common patterns in naming the test methods:\n * - [detectPrefix] removes unnecessary \"test\" prefix in all unit test.\n * - [detectFormat] Checks the `given_when_then` format of Android instrumented tests (backticks are not supported).\n */\nclass TestMethodNameDetector : Detector(), SourceCodeScanner {\n\n    override fun applicableAnnotations() = listOf(\"org.junit.Test\")\n\n    override fun visitAnnotationUsage(\n        context: JavaContext,\n        element: UElement,\n        annotationInfo: AnnotationInfo,\n        usageInfo: AnnotationUsageInfo,\n    ) {\n        val method = usageInfo.referenced as? PsiMethod ?: return\n\n        method.detectPrefix(context, usageInfo)\n        method.detectFormat(context, usageInfo)\n    }\n\n    private fun JavaContext.isAndroidTest() = Path(\"androidTest\") in file.toPath()\n\n    private fun PsiMethod.detectPrefix(\n        context: JavaContext,\n        usageInfo: AnnotationUsageInfo,\n    ) {\n        if (!name.startsWith(\"test\")) return\n        context.report(\n            issue = PREFIX,\n            scope = usageInfo.usage,\n            location = context.getNameLocation(this),\n            message = PREFIX.getBriefDescription(RAW),\n            quickfixData = LintFix.create()\n                .name(\"Remove prefix\")\n                .replace().pattern(\"\"\"test[\\s_]*\"\"\")\n                .with(\"\")\n                .autoFix()\n                .build(),\n        )\n    }\n\n    private fun PsiMethod.detectFormat(\n        context: JavaContext,\n        usageInfo: AnnotationUsageInfo,\n    ) {\n        if (!context.isAndroidTest()) return\n        if (\"\"\"[^\\W_]+(_[^\\W_]+){1,2}\"\"\".toRegex().matches(name)) return\n        context.report(\n            issue = FORMAT,\n            scope = usageInfo.usage,\n            location = context.getNameLocation(this),\n            message = FORMAT.getBriefDescription(RAW),\n        )\n    }\n\n    companion object {\n\n        private fun issue(\n            id: String,\n            briefDescription: String,\n            explanation: String,\n        ): Issue = Issue.create(\n            id = id,\n            briefDescription = briefDescription,\n            explanation = explanation,\n            category = TESTING,\n            priority = 5,\n            severity = WARNING,\n            implementation = Implementation(\n                TestMethodNameDetector::class.java,\n                EnumSet.of(JAVA_FILE, TEST_SOURCES),\n            ),\n        )\n\n        @JvmField\n        val PREFIX: Issue = issue(\n            id = \"TestMethodPrefix\",\n            briefDescription = \"Test method starts with `test`\",\n            explanation = \"Test method should not start with `test`.\",\n        )\n\n        @JvmField\n        val FORMAT: Issue = issue(\n            id = \"TestMethodFormat\",\n            briefDescription = \"Test method does not follow the `given_when_then` or `when_then` format\",\n            explanation = \"Test method should follow the `given_when_then` or `when_then` format.\",\n        )\n    }\n}\n"
  },
  {
    "path": "lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemDetector.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.lint.designsystem\n\nimport com.android.tools.lint.client.api.UElementHandler\nimport com.android.tools.lint.detector.api.Category\nimport com.android.tools.lint.detector.api.Detector\nimport com.android.tools.lint.detector.api.Implementation\nimport com.android.tools.lint.detector.api.Issue\nimport com.android.tools.lint.detector.api.JavaContext\nimport com.android.tools.lint.detector.api.Scope\nimport com.android.tools.lint.detector.api.Severity\nimport org.jetbrains.uast.UCallExpression\nimport org.jetbrains.uast.UElement\nimport org.jetbrains.uast.UQualifiedReferenceExpression\n\n/**\n * A detector that checks for incorrect usages of Compose Material APIs over equivalents in\n * the Now in Android design system module.\n */\nclass DesignSystemDetector : Detector(), Detector.UastScanner {\n\n    override fun getApplicableUastTypes(): List<Class<out UElement>> = listOf(\n        UCallExpression::class.java,\n        UQualifiedReferenceExpression::class.java,\n    )\n\n    override fun createUastHandler(context: JavaContext): UElementHandler =\n        object : UElementHandler() {\n            override fun visitCallExpression(node: UCallExpression) {\n                val name = node.methodName ?: return\n                val preferredName = METHOD_NAMES[name] ?: return\n                reportIssue(context, node, name, preferredName)\n            }\n\n            override fun visitQualifiedReferenceExpression(node: UQualifiedReferenceExpression) {\n                val name = node.receiver.asRenderString()\n                val preferredName = RECEIVER_NAMES[name] ?: return\n                reportIssue(context, node, name, preferredName)\n            }\n        }\n\n    companion object {\n        @JvmField\n        val ISSUE: Issue = Issue.create(\n            id = \"DesignSystem\",\n            briefDescription = \"Design system\",\n            explanation = \"This check highlights calls in code that use Compose Material \" +\n                \"composables instead of equivalents from the Now in Android design system \" +\n                \"module.\",\n            category = Category.CUSTOM_LINT_CHECKS,\n            priority = 7,\n            severity = Severity.ERROR,\n            implementation = Implementation(\n                DesignSystemDetector::class.java,\n                Scope.JAVA_FILE_SCOPE,\n            ),\n        )\n\n        // Unfortunately :lint is a Java module and thus can't depend on the :core-designsystem\n        // Android module, so we can't use composable function references (eg. ::Button.name)\n        // instead of hardcoded names.\n        val METHOD_NAMES = mapOf(\n            \"MaterialTheme\" to \"NiaTheme\",\n            \"Button\" to \"NiaButton\",\n            \"OutlinedButton\" to \"NiaOutlinedButton\",\n            \"TextButton\" to \"NiaTextButton\",\n            \"FilterChip\" to \"NiaFilterChip\",\n            \"ElevatedFilterChip\" to \"NiaFilterChip\",\n            \"NavigationBar\" to \"NiaNavigationBar\",\n            \"NavigationBarItem\" to \"NiaNavigationBarItem\",\n            \"NavigationRail\" to \"NiaNavigationRail\",\n            \"NavigationRailItem\" to \"NiaNavigationRailItem\",\n            \"TabRow\" to \"NiaTabRow\",\n            \"Tab\" to \"NiaTab\",\n            \"IconToggleButton\" to \"NiaIconToggleButton\",\n            \"FilledIconToggleButton\" to \"NiaIconToggleButton\",\n            \"FilledTonalIconToggleButton\" to \"NiaIconToggleButton\",\n            \"OutlinedIconToggleButton\" to \"NiaIconToggleButton\",\n            \"CenterAlignedTopAppBar\" to \"NiaTopAppBar\",\n            \"SmallTopAppBar\" to \"NiaTopAppBar\",\n            \"MediumTopAppBar\" to \"NiaTopAppBar\",\n            \"LargeTopAppBar\" to \"NiaTopAppBar\",\n        )\n        val RECEIVER_NAMES = mapOf(\n            \"Icons\" to \"NiaIcons\",\n        )\n\n        fun reportIssue(\n            context: JavaContext,\n            node: UElement,\n            name: String,\n            preferredName: String,\n        ) {\n            context.report(\n                ISSUE,\n                node,\n                context.getLocation(node),\n                \"Using $name instead of $preferredName\",\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "lint/src/main/resources/META-INF/services/com.android.tools.lint.client.api.IssueRegistry",
    "content": "#\n#  Copyright 2022 The Android Open Source Project\n#\n#  Licensed under the Apache License, Version 2.0 (the \"License\");\n#  you may not use this file except in compliance with the License.\n#  You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n#  Unless required by applicable law or agreed to in writing, software\n#  distributed under the License is distributed on an \"AS IS\" BASIS,\n#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n#  See the License for the specific language governing permissions and\n#  limitations under the License.\n#\n\ncom.google.samples.apps.nowinandroid.lint.NiaIssueRegistry\n"
  },
  {
    "path": "lint/src/test/kotlin/com/google/samples/apps/nowinandroid/lint/TestMethodNameDetectorTest.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.lint\n\nimport com.android.tools.lint.checks.infrastructure.TestFile\nimport com.android.tools.lint.checks.infrastructure.TestFiles.kotlin\nimport com.android.tools.lint.checks.infrastructure.TestLintTask.lint\nimport com.google.samples.apps.nowinandroid.lint.TestMethodNameDetector.Companion.FORMAT\nimport com.google.samples.apps.nowinandroid.lint.TestMethodNameDetector.Companion.PREFIX\nimport org.junit.Test\n\nclass TestMethodNameDetectorTest {\n\n    @Test\n    fun `detect prefix`() {\n        lint().issues(PREFIX)\n            .files(\n                JUNIT_TEST_STUB,\n                kotlin(\n                    \"\"\"\n                    import org.junit.Test\n                    class Test {\n                        @Test\n                        fun foo() = Unit\n                        @Test\n                        fun test_foo() = Unit\n                        @Test\n                        fun `test foo`() = Unit\n                    }\n                \"\"\",\n                ).indented(),\n            )\n            .run()\n            .expect(\n                \"\"\"\n                src/Test.kt:6: Warning: Test method starts with test [TestMethodPrefix]\n                    fun test_foo() = Unit\n                        ~~~~~~~~\n                src/Test.kt:8: Warning: Test method starts with test [TestMethodPrefix]\n                    fun `test foo`() = Unit\n                        ~~~~~~~~~~\n                0 errors, 2 warnings\n                \"\"\".trimIndent(),\n            )\n            .expectFixDiffs(\n                \"\"\"\n                Autofix for src/Test.kt line 6: Remove prefix:\n                @@ -6 +6\n                -     fun test_foo() = Unit\n                +     fun foo() = Unit\n                Autofix for src/Test.kt line 8: Remove prefix:\n                @@ -8 +8\n                -     fun `test foo`() = Unit\n                +     fun `foo`() = Unit\n                \"\"\".trimIndent(),\n            )\n    }\n\n    @Test\n    fun `detect format`() {\n        lint().issues(FORMAT)\n            .files(\n                JUNIT_TEST_STUB,\n                kotlin(\n                    \"src/androidTest/com/example/Test.kt\",\n                    \"\"\"\n                    import org.junit.Test\n                    class Test {\n                        @Test\n                        fun when_then() = Unit\n                        @Test\n                        fun given_when_then() = Unit\n\n                        @Test\n                        fun foo() = Unit\n                        @Test\n                        fun foo_bar_baz_qux() = Unit\n                        @Test\n                        fun `foo bar baz`() = Unit\n                    }\n                \"\"\",\n                ).indented(),\n            )\n            .run()\n            .expect(\n                \"\"\"\n                src/androidTest/com/example/Test.kt:9: Warning: Test method does not follow the given_when_then or when_then format [TestMethodFormat]\n                    fun foo() = Unit\n                        ~~~\n                src/androidTest/com/example/Test.kt:11: Warning: Test method does not follow the given_when_then or when_then format [TestMethodFormat]\n                    fun foo_bar_baz_qux() = Unit\n                        ~~~~~~~~~~~~~~~\n                src/androidTest/com/example/Test.kt:13: Warning: Test method does not follow the given_when_then or when_then format [TestMethodFormat]\n                    fun `foo bar baz`() = Unit\n                        ~~~~~~~~~~~~~\n                0 errors, 3 warnings\n                \"\"\".trimIndent(),\n            )\n    }\n\n    private companion object {\n        private val JUNIT_TEST_STUB: TestFile = kotlin(\n            \"\"\"\n                package org.junit\n                annotation class Test\n                \"\"\",\n        ).indented()\n    }\n}\n"
  },
  {
    "path": "lint/src/test/kotlin/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemDetectorTest.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.lint.designsystem\n\nimport com.android.tools.lint.checks.infrastructure.TestFile\nimport com.android.tools.lint.checks.infrastructure.TestFiles.kotlin\nimport com.android.tools.lint.checks.infrastructure.TestLintTask.lint\nimport com.google.samples.apps.nowinandroid.lint.designsystem.DesignSystemDetector.Companion.ISSUE\nimport com.google.samples.apps.nowinandroid.lint.designsystem.DesignSystemDetector.Companion.METHOD_NAMES\nimport com.google.samples.apps.nowinandroid.lint.designsystem.DesignSystemDetector.Companion.RECEIVER_NAMES\nimport org.junit.Test\n\nclass DesignSystemDetectorTest {\n\n    @Test\n    fun `detect replacements of Composable`() {\n        lint()\n            .issues(ISSUE)\n            .allowMissingSdk()\n            .files(\n                COMPOSABLE_STUB,\n                STUBS,\n                @Suppress(\"LintImplTrimIndent\")\n                kotlin(\n                    \"\"\"\n                    |import androidx.compose.runtime.Composable\n                    |\n                    |@Composable\n                    |fun App() {\n                    ${METHOD_NAMES.keys.joinToString(\"\\n\") { \"|    $it()\" }}\n                    |}\n                    \"\"\".trimMargin(),\n                ).indented(),\n            )\n            .run()\n            .expect(\n                \"\"\"\n                src/test.kt:5: Error: Using MaterialTheme instead of NiaTheme [DesignSystem]\n                    MaterialTheme()\n                    ~~~~~~~~~~~~~~~\n                src/test.kt:6: Error: Using Button instead of NiaButton [DesignSystem]\n                    Button()\n                    ~~~~~~~~\n                src/test.kt:7: Error: Using OutlinedButton instead of NiaOutlinedButton [DesignSystem]\n                    OutlinedButton()\n                    ~~~~~~~~~~~~~~~~\n                src/test.kt:8: Error: Using TextButton instead of NiaTextButton [DesignSystem]\n                    TextButton()\n                    ~~~~~~~~~~~~\n                src/test.kt:9: Error: Using FilterChip instead of NiaFilterChip [DesignSystem]\n                    FilterChip()\n                    ~~~~~~~~~~~~\n                src/test.kt:10: Error: Using ElevatedFilterChip instead of NiaFilterChip [DesignSystem]\n                    ElevatedFilterChip()\n                    ~~~~~~~~~~~~~~~~~~~~\n                src/test.kt:11: Error: Using NavigationBar instead of NiaNavigationBar [DesignSystem]\n                    NavigationBar()\n                    ~~~~~~~~~~~~~~~\n                src/test.kt:12: Error: Using NavigationBarItem instead of NiaNavigationBarItem [DesignSystem]\n                    NavigationBarItem()\n                    ~~~~~~~~~~~~~~~~~~~\n                src/test.kt:13: Error: Using NavigationRail instead of NiaNavigationRail [DesignSystem]\n                    NavigationRail()\n                    ~~~~~~~~~~~~~~~~\n                src/test.kt:14: Error: Using NavigationRailItem instead of NiaNavigationRailItem [DesignSystem]\n                    NavigationRailItem()\n                    ~~~~~~~~~~~~~~~~~~~~\n                src/test.kt:15: Error: Using TabRow instead of NiaTabRow [DesignSystem]\n                    TabRow()\n                    ~~~~~~~~\n                src/test.kt:16: Error: Using Tab instead of NiaTab [DesignSystem]\n                    Tab()\n                    ~~~~~\n                src/test.kt:17: Error: Using IconToggleButton instead of NiaIconToggleButton [DesignSystem]\n                    IconToggleButton()\n                    ~~~~~~~~~~~~~~~~~~\n                src/test.kt:18: Error: Using FilledIconToggleButton instead of NiaIconToggleButton [DesignSystem]\n                    FilledIconToggleButton()\n                    ~~~~~~~~~~~~~~~~~~~~~~~~\n                src/test.kt:19: Error: Using FilledTonalIconToggleButton instead of NiaIconToggleButton [DesignSystem]\n                    FilledTonalIconToggleButton()\n                    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n                src/test.kt:20: Error: Using OutlinedIconToggleButton instead of NiaIconToggleButton [DesignSystem]\n                    OutlinedIconToggleButton()\n                    ~~~~~~~~~~~~~~~~~~~~~~~~~~\n                src/test.kt:21: Error: Using CenterAlignedTopAppBar instead of NiaTopAppBar [DesignSystem]\n                    CenterAlignedTopAppBar()\n                    ~~~~~~~~~~~~~~~~~~~~~~~~\n                src/test.kt:22: Error: Using SmallTopAppBar instead of NiaTopAppBar [DesignSystem]\n                    SmallTopAppBar()\n                    ~~~~~~~~~~~~~~~~\n                src/test.kt:23: Error: Using MediumTopAppBar instead of NiaTopAppBar [DesignSystem]\n                    MediumTopAppBar()\n                    ~~~~~~~~~~~~~~~~~\n                src/test.kt:24: Error: Using LargeTopAppBar instead of NiaTopAppBar [DesignSystem]\n                    LargeTopAppBar()\n                    ~~~~~~~~~~~~~~~~\n                20 errors, 0 warnings\n                \"\"\".trimIndent(),\n            )\n    }\n\n    @Test\n    fun `detect replacements of Receiver`() {\n        lint()\n            .issues(ISSUE)\n            .allowMissingSdk()\n            .files(\n                COMPOSABLE_STUB,\n                STUBS,\n                @Suppress(\"LintImplTrimIndent\")\n                kotlin(\n                    \"\"\"\n                    |fun main() {\n                    ${RECEIVER_NAMES.keys.joinToString(\"\\n\") { \"|    $it.toString()\" }}\n                    |}\n                    \"\"\".trimMargin(),\n                ).indented(),\n            )\n            .run()\n            .expect(\n                \"\"\"\n                src/test.kt:2: Error: Using Icons instead of NiaIcons [DesignSystem]\n                    Icons.toString()\n                    ~~~~~~~~~~~~~~~~\n                1 errors, 0 warnings\n                \"\"\".trimIndent(),\n            )\n    }\n\n    private companion object {\n\n        private val COMPOSABLE_STUB: TestFile = kotlin(\n            \"\"\"\n            package androidx.compose.runtime\n            annotation class Composable\n            \"\"\".trimIndent(),\n        ).indented()\n\n        private val STUBS: TestFile = kotlin(\n            \"\"\"\n            |import androidx.compose.runtime.Composable\n            |\n            ${METHOD_NAMES.keys.joinToString(\"\\n\") { \"|@Composable fun $it() = {}\" }}\n            ${RECEIVER_NAMES.keys.joinToString(\"\\n\") { \"|object $it\" }}\n            |\n            \"\"\".trimMargin(),\n        ).indented()\n    }\n}\n"
  },
  {
    "path": "settings.gradle.kts",
    "content": "/*\n * Copyright 2021 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npluginManagement {\n    includeBuild(\"build-logic\")\n    repositories {\n        google {\n            content {\n                includeGroupByRegex(\"com\\\\.android.*\")\n                includeGroupByRegex(\"com\\\\.google.*\")\n                includeGroupByRegex(\"androidx.*\")\n            }\n        }\n        mavenCentral()\n        gradlePluginPortal()\n    }\n}\n\ndependencyResolutionManagement {\n    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS\n    repositories {\n        google {\n            content {\n                includeGroupByRegex(\"com\\\\.android.*\")\n                includeGroupByRegex(\"com\\\\.google.*\")\n                includeGroupByRegex(\"androidx.*\")\n            }\n        }\n        mavenCentral()\n    }\n}\nrootProject.name = \"nowinandroid\"\n\nenableFeaturePreview(\"TYPESAFE_PROJECT_ACCESSORS\")\ninclude(\":app\")\ninclude(\":app-nia-catalog\")\ninclude(\":benchmarks\")\ninclude(\":core:analytics\")\ninclude(\":core:common\")\ninclude(\":core:data\")\ninclude(\":core:data-test\")\ninclude(\":core:database\")\ninclude(\":core:datastore\")\ninclude(\":core:datastore-proto\")\ninclude(\":core:datastore-test\")\ninclude(\":core:designsystem\")\ninclude(\":core:domain\")\ninclude(\":core:model\")\ninclude(\":core:navigation\")\ninclude(\":core:network\")\ninclude(\":core:notifications\")\ninclude(\":core:screenshot-testing\")\ninclude(\":core:testing\")\ninclude(\":core:ui\")\n\ninclude(\":feature:foryou:api\")\ninclude(\":feature:foryou:impl\")\ninclude(\":feature:interests:api\")\ninclude(\":feature:interests:impl\")\ninclude(\":feature:bookmarks:api\")\ninclude(\":feature:bookmarks:impl\")\ninclude(\":feature:topic:api\")\ninclude(\":feature:topic:impl\")\ninclude(\":feature:search:api\")\ninclude(\":feature:search:impl\")\ninclude(\":feature:settings:impl\")\ninclude(\":lint\")\ninclude(\":sync:work\")\ninclude(\":sync:sync-test\")\ninclude(\":ui-test-hilt-manifest\")\n\ncheck(JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_17)) {\n    \"\"\"\n    Now in Android requires JDK 17+ but it is currently using JDK ${JavaVersion.current()}.\n    Java Home: [${System.getProperty(\"java.home\")}]\n    https://developer.android.com/build/jdks#jdk-config-in-studio\n    \"\"\".trimIndent()\n}\n"
  },
  {
    "path": "spotless/copyright.kt",
    "content": "/*\n * Copyright $YEAR The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n"
  },
  {
    "path": "spotless/copyright.kts",
    "content": "/*\n * Copyright $YEAR The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n"
  },
  {
    "path": "spotless/copyright.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright $YEAR The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n"
  },
  {
    "path": "sync/sync-test/.gitignore",
    "content": "/build"
  },
  {
    "path": "sync/sync-test/README.md",
    "content": "# `:sync:sync-test`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :sync\n    direction TB\n    :sync:sync-test[sync-test]:::android-library\n    :sync:work[work]:::android-library\n  end\n  subgraph :core\n    direction TB\n    :core:analytics[analytics]:::android-library\n    :core:common[common]:::jvm-library\n    :core:data[data]:::android-library\n    :core:database[database]:::android-library\n    :core:datastore[datastore]:::android-library\n    :core:datastore-proto[datastore-proto]:::jvm-library\n    :core:model[model]:::jvm-library\n    :core:network[network]:::android-library\n    :core:notifications[notifications]:::android-library\n  end\n\n  :core:data -.-> :core:analytics\n  :core:data --> :core:common\n  :core:data --> :core:database\n  :core:data --> :core:datastore\n  :core:data --> :core:network\n  :core:data -.-> :core:notifications\n  :core:database --> :core:model\n  :core:datastore -.-> :core:common\n  :core:datastore --> :core:datastore-proto\n  :core:datastore --> :core:model\n  :core:network --> :core:common\n  :core:network --> :core:model\n  :core:notifications -.-> :core:common\n  :core:notifications --> :core:model\n  :sync:sync-test -.-> :core:data\n  :sync:sync-test -.-> :sync:work\n  :sync:work -.-> :core:analytics\n  :sync:work -.-> :core:data\n  :sync:work -.-> :core:notifications\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "sync/sync-test/build.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nplugins {\n    alias(libs.plugins.nowinandroid.android.library)\n    alias(libs.plugins.nowinandroid.hilt)\n}\n\nandroid {\n    namespace = \"com.google.samples.apps.nowinandroid.core.sync.test\"\n}\n\ndependencies {\n    implementation(libs.hilt.android.testing)\n    implementation(projects.core.data)\n    implementation(projects.sync.work)\n}\n"
  },
  {
    "path": "sync/sync-test/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<manifest />\n"
  },
  {
    "path": "sync/sync-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/sync/test/NeverSyncingSyncManager.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.sync.test\n\nimport com.google.samples.apps.nowinandroid.core.data.util.SyncManager\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.flowOf\nimport javax.inject.Inject\n\ninternal class NeverSyncingSyncManager @Inject constructor() : SyncManager {\n    override val isSyncing: Flow<Boolean> = flowOf(false)\n    override fun requestSync() = Unit\n}\n"
  },
  {
    "path": "sync/sync-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/sync/test/TestSyncModule.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.core.sync.test\n\nimport com.google.samples.apps.nowinandroid.core.data.util.SyncManager\nimport com.google.samples.apps.nowinandroid.sync.di.SyncModule\nimport com.google.samples.apps.nowinandroid.sync.status.StubSyncSubscriber\nimport com.google.samples.apps.nowinandroid.sync.status.SyncSubscriber\nimport dagger.Binds\nimport dagger.Module\nimport dagger.hilt.components.SingletonComponent\nimport dagger.hilt.testing.TestInstallIn\n\n@Module\n@TestInstallIn(\n    components = [SingletonComponent::class],\n    replaces = [SyncModule::class],\n)\ninternal interface TestSyncModule {\n    @Binds\n    fun bindsSyncStatusMonitor(\n        syncStatusMonitor: NeverSyncingSyncManager,\n    ): SyncManager\n\n    @Binds\n    fun bindsSyncSubscriber(\n        syncSubscriber: StubSyncSubscriber,\n    ): SyncSubscriber\n}\n"
  },
  {
    "path": "sync/work/.gitignore",
    "content": "/build"
  },
  {
    "path": "sync/work/README.md",
    "content": "# `:sync:work`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  subgraph :sync\n    direction TB\n    :sync:work[work]:::android-library\n  end\n  subgraph :core\n    direction TB\n    :core:analytics[analytics]:::android-library\n    :core:common[common]:::jvm-library\n    :core:data[data]:::android-library\n    :core:database[database]:::android-library\n    :core:datastore[datastore]:::android-library\n    :core:datastore-proto[datastore-proto]:::jvm-library\n    :core:model[model]:::jvm-library\n    :core:network[network]:::android-library\n    :core:notifications[notifications]:::android-library\n  end\n\n  :core:data -.-> :core:analytics\n  :core:data --> :core:common\n  :core:data --> :core:database\n  :core:data --> :core:datastore\n  :core:data --> :core:network\n  :core:data -.-> :core:notifications\n  :core:database --> :core:model\n  :core:datastore -.-> :core:common\n  :core:datastore --> :core:datastore-proto\n  :core:datastore --> :core:model\n  :core:network --> :core:common\n  :core:network --> :core:model\n  :core:notifications -.-> :core:common\n  :core:notifications --> :core:model\n  :sync:work -.-> :core:analytics\n  :sync:work -.-> :core:data\n  :sync:work -.-> :core:notifications\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "sync/work/build.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nplugins {\n    alias(libs.plugins.nowinandroid.android.library)\n    alias(libs.plugins.nowinandroid.android.library.jacoco)\n    alias(libs.plugins.nowinandroid.hilt)\n}\n\nandroid {\n    defaultConfig {\n        testInstrumentationRunner = \"com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner\"\n    }\n    namespace = \"com.google.samples.apps.nowinandroid.sync\"\n}\n\ndependencies {\n    ksp(libs.hilt.ext.compiler)\n\n    implementation(libs.androidx.tracing.ktx)\n    implementation(libs.androidx.work.ktx)\n    implementation(libs.hilt.ext.work)\n    implementation(projects.core.analytics)\n    implementation(projects.core.data)\n    implementation(projects.core.notifications)\n\n    prodImplementation(libs.firebase.cloud.messaging)\n    prodImplementation(platform(libs.firebase.bom))\n\n    androidTestImplementation(libs.androidx.work.testing)\n    androidTestImplementation(libs.hilt.android.testing)\n    androidTestImplementation(libs.kotlinx.coroutines.guava)\n    androidTestImplementation(projects.core.testing)\n}\n"
  },
  {
    "path": "sync/work/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/sync/workers/SyncWorkerTest.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.sync.workers\n\nimport android.util.Log\nimport androidx.test.platform.app.InstrumentationRegistry\nimport androidx.work.Configuration\nimport androidx.work.WorkInfo\nimport androidx.work.WorkManager\nimport androidx.work.testing.SynchronousExecutor\nimport androidx.work.testing.WorkManagerTestInitHelper\nimport dagger.hilt.android.testing.HiltAndroidRule\nimport dagger.hilt.android.testing.HiltAndroidTest\nimport org.junit.Before\nimport org.junit.Rule\nimport org.junit.Test\nimport kotlin.test.assertEquals\n\n@HiltAndroidTest\nclass SyncWorkerTest {\n\n    @get:Rule(order = 0)\n    val hiltRule = HiltAndroidRule(this)\n\n    private val context get() = InstrumentationRegistry.getInstrumentation().context\n\n    @Before\n    fun setup() {\n        val config = Configuration.Builder()\n            .setMinimumLoggingLevel(Log.DEBUG)\n            .setExecutor(SynchronousExecutor())\n            .build()\n\n        // Initialize WorkManager for instrumentation tests.\n        WorkManagerTestInitHelper.initializeTestWorkManager(context, config)\n    }\n\n    @Test\n    fun testSyncWork() {\n        // Create request\n        val request = SyncWorker.startUpSyncWork()\n\n        val workManager = WorkManager.getInstance(context)\n        val testDriver = WorkManagerTestInitHelper.getTestDriver(context)!!\n\n        // Enqueue and wait for result.\n        workManager.enqueue(request).result.get()\n\n        // Get WorkInfo and outputData\n        val preRunWorkInfo = workManager.getWorkInfoById(request.id).get()\n\n        // Assert\n        assertEquals(WorkInfo.State.ENQUEUED, preRunWorkInfo?.state)\n\n        // Tells the testing framework that the constraints have been met\n        testDriver.setAllConstraintsMet(request.id)\n\n        val postRequirementWorkInfo = workManager.getWorkInfoById(request.id).get()\n        assertEquals(WorkInfo.State.RUNNING, postRequirementWorkInfo?.state)\n    }\n}\n"
  },
  {
    "path": "sync/work/src/demo/kotlin/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.sync.di\n\nimport com.google.samples.apps.nowinandroid.core.data.util.SyncManager\nimport com.google.samples.apps.nowinandroid.sync.status.StubSyncSubscriber\nimport com.google.samples.apps.nowinandroid.sync.status.SyncSubscriber\nimport com.google.samples.apps.nowinandroid.sync.status.WorkManagerSyncManager\nimport dagger.Binds\nimport dagger.Module\nimport dagger.hilt.InstallIn\nimport dagger.hilt.components.SingletonComponent\n\n@Module\n@InstallIn(SingletonComponent::class)\nabstract class SyncModule {\n    @Binds\n    internal abstract fun bindsSyncStatusMonitor(\n        syncStatusMonitor: WorkManagerSyncManager,\n    ): SyncManager\n\n    @Binds\n    internal abstract fun bindsSyncSubscriber(\n        syncSubscriber: StubSyncSubscriber,\n    ): SyncSubscriber\n}\n"
  },
  {
    "path": "sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.sync.initializers\n\nimport android.content.Context\nimport androidx.work.ExistingWorkPolicy\nimport androidx.work.WorkManager\nimport com.google.samples.apps.nowinandroid.sync.workers.SyncWorker\n\nobject Sync {\n    // This method is initializes sync, the process that keeps the app's data current.\n    // It is called from the app module's Application.onCreate() and should be only done once.\n    fun initialize(context: Context) {\n        WorkManager.getInstance(context).apply {\n            // Run sync on app startup and ensure only one sync worker runs at any time\n            enqueueUniqueWork(\n                SYNC_WORK_NAME,\n                ExistingWorkPolicy.KEEP,\n                SyncWorker.startUpSyncWork(),\n            )\n        }\n    }\n}\n\n// This name should not be changed otherwise the app may have concurrent sync requests running\ninternal const val SYNC_WORK_NAME = \"SyncWorkName\"\n"
  },
  {
    "path": "sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/initializers/SyncWorkHelpers.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.sync.initializers\n\nimport android.app.Notification\nimport android.app.NotificationChannel\nimport android.app.NotificationManager\nimport android.content.Context\nimport android.os.Build\nimport androidx.core.app.NotificationCompat\nimport androidx.work.Constraints\nimport androidx.work.ForegroundInfo\nimport androidx.work.NetworkType\nimport com.google.samples.apps.nowinandroid.sync.R\n\nconst val SYNC_TOPIC = \"sync\"\nprivate const val SYNC_NOTIFICATION_ID = 0\nprivate const val SYNC_NOTIFICATION_CHANNEL_ID = \"SyncNotificationChannel\"\n\n// All sync work needs an internet connectionS\nval SyncConstraints\n    get() = Constraints.Builder()\n        .setRequiredNetworkType(NetworkType.CONNECTED)\n        .build()\n\n/**\n * Foreground information for sync on lower API levels when sync workers are being\n * run with a foreground service\n */\nfun Context.syncForegroundInfo() = ForegroundInfo(\n    SYNC_NOTIFICATION_ID,\n    syncWorkNotification(),\n)\n\n/**\n * Notification displayed on lower API levels when sync workers are being\n * run with a foreground service\n */\nprivate fun Context.syncWorkNotification(): Notification {\n    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {\n        val channel = NotificationChannel(\n            SYNC_NOTIFICATION_CHANNEL_ID,\n            getString(R.string.sync_work_notification_channel_name),\n            NotificationManager.IMPORTANCE_DEFAULT,\n        ).apply {\n            description = getString(R.string.sync_work_notification_channel_description)\n        }\n        // Register the channel with the system\n        val notificationManager: NotificationManager? =\n            getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager\n\n        notificationManager?.createNotificationChannel(channel)\n    }\n\n    return NotificationCompat.Builder(\n        this,\n        SYNC_NOTIFICATION_CHANNEL_ID,\n    )\n        .setSmallIcon(\n            com.google.samples.apps.nowinandroid.core.notifications.R.drawable.core_notifications_ic_nia_notification,\n        )\n        .setContentTitle(getString(R.string.sync_work_notification_title))\n        .setPriority(NotificationCompat.PRIORITY_DEFAULT)\n        .build()\n}\n"
  },
  {
    "path": "sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/status/StubSyncSubscriber.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.sync.status\n\nimport android.util.Log\nimport javax.inject.Inject\n\nprivate const val TAG = \"StubSyncSubscriber\"\n\n/**\n * Stub implementation of [SyncSubscriber]\n */\nclass StubSyncSubscriber @Inject constructor() : SyncSubscriber {\n    override suspend fun subscribe() {\n        Log.d(TAG, \"Subscribing to sync\")\n    }\n}\n"
  },
  {
    "path": "sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/status/SyncSubscriber.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.sync.status\n\n/**\n * Subscribes to backend requested synchronization\n */\ninterface SyncSubscriber {\n    suspend fun subscribe()\n}\n"
  },
  {
    "path": "sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/status/WorkManagerSyncManager.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.sync.status\n\nimport android.content.Context\nimport androidx.work.ExistingWorkPolicy\nimport androidx.work.WorkInfo\nimport androidx.work.WorkInfo.State\nimport androidx.work.WorkManager\nimport com.google.samples.apps.nowinandroid.core.data.util.SyncManager\nimport com.google.samples.apps.nowinandroid.sync.initializers.SYNC_WORK_NAME\nimport com.google.samples.apps.nowinandroid.sync.workers.SyncWorker\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.conflate\nimport kotlinx.coroutines.flow.map\nimport javax.inject.Inject\n\n/**\n * [SyncManager] backed by [WorkInfo] from [WorkManager]\n */\ninternal class WorkManagerSyncManager @Inject constructor(\n    @ApplicationContext private val context: Context,\n) : SyncManager {\n    override val isSyncing: Flow<Boolean> =\n        WorkManager.getInstance(context).getWorkInfosForUniqueWorkFlow(SYNC_WORK_NAME)\n            .map(List<WorkInfo>::anyRunning)\n            .conflate()\n\n    override fun requestSync() {\n        val workManager = WorkManager.getInstance(context)\n        // Run sync on app startup and ensure only one sync worker runs at any time\n        workManager.enqueueUniqueWork(\n            SYNC_WORK_NAME,\n            ExistingWorkPolicy.KEEP,\n            SyncWorker.startUpSyncWork(),\n        )\n    }\n}\n\nprivate fun List<WorkInfo>.anyRunning() = any { it.state == State.RUNNING }\n"
  },
  {
    "path": "sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/workers/AnalyticsExtensions.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.sync.workers\n\nimport com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent\nimport com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper\n\ninternal fun AnalyticsHelper.logSyncStarted() =\n    logEvent(\n        AnalyticsEvent(type = \"network_sync_started\"),\n    )\n\ninternal fun AnalyticsHelper.logSyncFinished(syncedSuccessfully: Boolean) {\n    val eventType = if (syncedSuccessfully) \"network_sync_successful\" else \"network_sync_failed\"\n    logEvent(\n        AnalyticsEvent(type = eventType),\n    )\n}\n"
  },
  {
    "path": "sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/workers/DelegatingWorker.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.sync.workers\n\nimport android.content.Context\nimport androidx.hilt.work.HiltWorkerFactory\nimport androidx.work.CoroutineWorker\nimport androidx.work.Data\nimport androidx.work.ForegroundInfo\nimport androidx.work.WorkerParameters\nimport dagger.hilt.EntryPoint\nimport dagger.hilt.InstallIn\nimport dagger.hilt.android.EntryPointAccessors\nimport dagger.hilt.components.SingletonComponent\nimport kotlin.reflect.KClass\n\n/**\n * An entry point to retrieve the [HiltWorkerFactory] at runtime\n */\n@EntryPoint\n@InstallIn(SingletonComponent::class)\ninterface HiltWorkerFactoryEntryPoint {\n    fun hiltWorkerFactory(): HiltWorkerFactory\n}\n\nprivate const val WORKER_CLASS_NAME = \"RouterWorkerDelegateClassName\"\n\n/**\n * Adds metadata to a WorkRequest to identify what [CoroutineWorker] the [DelegatingWorker] should\n * delegate to\n */\ninternal fun KClass<out CoroutineWorker>.delegatedData() =\n    Data.Builder()\n        .putString(WORKER_CLASS_NAME, qualifiedName)\n        .build()\n\n/**\n * A worker that delegates sync to another [CoroutineWorker] constructed with a [HiltWorkerFactory].\n *\n * This allows for creating and using [CoroutineWorker] instances with extended arguments\n * without having to provide a custom WorkManager configuration that the app module needs to utilize.\n *\n * In other words, it allows for custom workers in a library module without having to own\n * configuration of the WorkManager singleton.\n */\nclass DelegatingWorker(\n    appContext: Context,\n    workerParams: WorkerParameters,\n) : CoroutineWorker(appContext, workerParams) {\n\n    private val workerClassName =\n        workerParams.inputData.getString(WORKER_CLASS_NAME) ?: \"\"\n\n    private val delegateWorker =\n        EntryPointAccessors.fromApplication<HiltWorkerFactoryEntryPoint>(appContext)\n            .hiltWorkerFactory()\n            .createWorker(appContext, workerClassName, workerParams)\n            as? CoroutineWorker\n            ?: throw IllegalArgumentException(\"Unable to find appropriate worker\")\n\n    override suspend fun getForegroundInfo(): ForegroundInfo =\n        delegateWorker.getForegroundInfo()\n\n    override suspend fun doWork(): Result =\n        delegateWorker.doWork()\n}\n"
  },
  {
    "path": "sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.sync.workers\n\nimport android.content.Context\nimport androidx.hilt.work.HiltWorker\nimport androidx.tracing.traceAsync\nimport androidx.work.CoroutineWorker\nimport androidx.work.ForegroundInfo\nimport androidx.work.OneTimeWorkRequestBuilder\nimport androidx.work.OutOfQuotaPolicy\nimport androidx.work.WorkerParameters\nimport com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper\nimport com.google.samples.apps.nowinandroid.core.common.network.Dispatcher\nimport com.google.samples.apps.nowinandroid.core.common.network.NiaDispatchers.IO\nimport com.google.samples.apps.nowinandroid.core.data.Synchronizer\nimport com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository\nimport com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository\nimport com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions\nimport com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource\nimport com.google.samples.apps.nowinandroid.sync.initializers.SyncConstraints\nimport com.google.samples.apps.nowinandroid.sync.initializers.syncForegroundInfo\nimport com.google.samples.apps.nowinandroid.sync.status.SyncSubscriber\nimport dagger.assisted.Assisted\nimport dagger.assisted.AssistedInject\nimport kotlinx.coroutines.CoroutineDispatcher\nimport kotlinx.coroutines.async\nimport kotlinx.coroutines.awaitAll\nimport kotlinx.coroutines.withContext\n\n/**\n * Syncs the data layer by delegating to the appropriate repository instances with\n * sync functionality.\n */\n@HiltWorker\ninternal class SyncWorker @AssistedInject constructor(\n    @Assisted private val appContext: Context,\n    @Assisted workerParams: WorkerParameters,\n    private val niaPreferences: NiaPreferencesDataSource,\n    private val topicRepository: TopicsRepository,\n    private val newsRepository: NewsRepository,\n    private val searchContentsRepository: SearchContentsRepository,\n    @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,\n    private val analyticsHelper: AnalyticsHelper,\n    private val syncSubscriber: SyncSubscriber,\n) : CoroutineWorker(appContext, workerParams), Synchronizer {\n\n    override suspend fun getForegroundInfo(): ForegroundInfo =\n        appContext.syncForegroundInfo()\n\n    override suspend fun doWork(): Result = withContext(ioDispatcher) {\n        traceAsync(\"Sync\", 0) {\n            analyticsHelper.logSyncStarted()\n\n            syncSubscriber.subscribe()\n\n            // First sync the repositories in parallel\n            val syncedSuccessfully = awaitAll(\n                async { topicRepository.sync() },\n                async { newsRepository.sync() },\n            ).all { it }\n\n            analyticsHelper.logSyncFinished(syncedSuccessfully)\n\n            if (syncedSuccessfully) {\n                searchContentsRepository.populateFtsData()\n                Result.success()\n            } else {\n                Result.retry()\n            }\n        }\n    }\n\n    override suspend fun getChangeListVersions(): ChangeListVersions =\n        niaPreferences.getChangeListVersions()\n\n    override suspend fun updateChangeListVersions(\n        update: ChangeListVersions.() -> ChangeListVersions,\n    ) = niaPreferences.updateChangeListVersion(update)\n\n    companion object {\n        /**\n         * Expedited one time work to sync data on app startup\n         */\n        fun startUpSyncWork() = OneTimeWorkRequestBuilder<DelegatingWorker>()\n            .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)\n            .setConstraints(SyncConstraints)\n            .setInputData(SyncWorker::class.delegatedData())\n            .build()\n    }\n}\n"
  },
  {
    "path": "sync/work/src/main/res/values/strings.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<resources>\n    <string name=\"sync_work_notification_title\">Now in Android</string>\n    <string name=\"sync_work_notification_channel_name\">Sync</string>\n    <string name=\"sync_work_notification_channel_description\">Background tasks for Now in Android</string>\n\n</resources>\n"
  },
  {
    "path": "sync/work/src/prod/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n\n    <application>\n        <service\n            android:name=\".services.SyncNotificationsService\"\n            android:exported=\"false\">\n            <intent-filter>\n                <action android:name=\"com.google.firebase.MESSAGING_EVENT\" />\n            </intent-filter>\n        </service>\n    </application>\n\n</manifest>\n"
  },
  {
    "path": "sync/work/src/prod/kotlin/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.sync.di\n\nimport com.google.firebase.Firebase\nimport com.google.firebase.messaging.FirebaseMessaging\nimport com.google.firebase.messaging.messaging\nimport com.google.samples.apps.nowinandroid.core.data.util.SyncManager\nimport com.google.samples.apps.nowinandroid.sync.status.FirebaseSyncSubscriber\nimport com.google.samples.apps.nowinandroid.sync.status.SyncSubscriber\nimport com.google.samples.apps.nowinandroid.sync.status.WorkManagerSyncManager\nimport dagger.Binds\nimport dagger.Module\nimport dagger.Provides\nimport dagger.hilt.InstallIn\nimport dagger.hilt.components.SingletonComponent\nimport javax.inject.Singleton\n\n@Module\n@InstallIn(SingletonComponent::class)\nabstract class SyncModule {\n    @Binds\n    internal abstract fun bindsSyncStatusMonitor(\n        syncStatusMonitor: WorkManagerSyncManager,\n    ): SyncManager\n\n    @Binds\n    internal abstract fun bindsSyncSubscriber(\n        syncSubscriber: FirebaseSyncSubscriber,\n    ): SyncSubscriber\n\n    companion object {\n        @Provides\n        @Singleton\n        internal fun provideFirebaseMessaging(): FirebaseMessaging = Firebase.messaging\n    }\n}\n"
  },
  {
    "path": "sync/work/src/prod/kotlin/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.sync.services\n\nimport com.google.firebase.messaging.FirebaseMessagingService\nimport com.google.firebase.messaging.RemoteMessage\nimport com.google.samples.apps.nowinandroid.core.data.util.SyncManager\nimport dagger.hilt.android.AndroidEntryPoint\nimport javax.inject.Inject\n\nprivate const val SYNC_TOPIC_SENDER = \"/topics/sync\"\n\n@AndroidEntryPoint\ninternal class SyncNotificationsService : FirebaseMessagingService() {\n\n    @Inject\n    lateinit var syncManager: SyncManager\n\n    override fun onMessageReceived(message: RemoteMessage) {\n        if (SYNC_TOPIC_SENDER == message.from) {\n            syncManager.requestSync()\n        }\n    }\n}\n"
  },
  {
    "path": "sync/work/src/prod/kotlin/com/google/samples/apps/nowinandroid/sync/status/FirebaseSyncSubscriber.kt",
    "content": "/*\n * Copyright 2023 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.sync.status\n\nimport com.google.firebase.messaging.FirebaseMessaging\nimport com.google.samples.apps.nowinandroid.sync.initializers.SYNC_TOPIC\nimport kotlinx.coroutines.tasks.await\nimport javax.inject.Inject\n\n/**\n * Implementation of [SyncSubscriber] that subscribes to the FCM [SYNC_TOPIC]\n */\ninternal class FirebaseSyncSubscriber @Inject constructor(\n    private val firebaseMessaging: FirebaseMessaging,\n) : SyncSubscriber {\n    override suspend fun subscribe() {\n        firebaseMessaging\n            .subscribeToTopic(SYNC_TOPIC)\n            .await()\n    }\n}\n"
  },
  {
    "path": "tools/nowinandroid-codestyle.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n  ~ Copyright 2021 Google LLC\n  ~\n  ~ Licensed under the Apache License, Version 2.0 (the \"License\");\n  ~ you may not use this file except in compliance with the License.\n  ~ You may obtain a copy of the License at\n  ~\n  ~     https://www.apache.org/licenses/LICENSE-2.0\n  ~\n  ~ Unless required by applicable law or agreed to in writing, software\n  ~ distributed under the License is distributed on an \"AS IS\" BASIS,\n  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n  ~ See the License for the specific language governing permissions and\n  ~ limitations under the License.\n-->\n<code_scheme name=\"nowinandroid\" version=\"1\">\n  <option name=\"RIGHT_MARGIN\" value=\"100\" />\n  <AndroidXmlCodeStyleSettings>\n    <option name=\"USE_CUSTOM_SETTINGS\" value=\"true\" />\n  </AndroidXmlCodeStyleSettings>\n  <JavaCodeStyleSettings>\n    <option name=\"FIELD_NAME_PREFIX\" value=\"m\" />\n    <option name=\"STATIC_FIELD_NAME_PREFIX\" value=\"s\" />\n    <option name=\"ANNOTATION_PARAMETER_WRAP\" value=\"1\" />\n    <option name=\"INSERT_INNER_CLASS_IMPORTS\" value=\"true\" />\n    <option name=\"CLASS_COUNT_TO_USE_IMPORT_ON_DEMAND\" value=\"9999\" />\n    <option name=\"NAMES_COUNT_TO_USE_IMPORT_ON_DEMAND\" value=\"9999\" />\n    <option name=\"IMPORT_LAYOUT_TABLE\">\n      <value>\n        <package name=\"android\" withSubpackages=\"true\" static=\"true\" />\n        <emptyLine />\n        <package name=\"com.android\" withSubpackages=\"true\" static=\"true\" />\n        <emptyLine />\n        <package name=\"dalvik\" withSubpackages=\"true\" static=\"true\" />\n        <emptyLine />\n        <package name=\"libcore\" withSubpackages=\"true\" static=\"true\" />\n        <emptyLine />\n        <package name=\"com\" withSubpackages=\"true\" static=\"true\" />\n        <emptyLine />\n        <package name=\"gov\" withSubpackages=\"true\" static=\"true\" />\n        <emptyLine />\n        <package name=\"junit\" withSubpackages=\"true\" static=\"true\" />\n        <emptyLine />\n        <package name=\"net\" withSubpackages=\"true\" static=\"true\" />\n        <emptyLine />\n        <package name=\"org\" withSubpackages=\"true\" static=\"true\" />\n        <emptyLine />\n        <package name=\"java\" withSubpackages=\"true\" static=\"true\" />\n        <emptyLine />\n        <package name=\"javax\" withSubpackages=\"true\" static=\"true\" />\n        <emptyLine />\n        <package name=\"\" withSubpackages=\"true\" static=\"true\" />\n        <emptyLine />\n        <package name=\"android\" withSubpackages=\"true\" static=\"false\" />\n        <emptyLine />\n        <package name=\"com.android\" withSubpackages=\"true\" static=\"false\" />\n        <emptyLine />\n        <package name=\"dalvik\" withSubpackages=\"true\" static=\"false\" />\n        <emptyLine />\n        <package name=\"libcore\" withSubpackages=\"true\" static=\"false\" />\n        <emptyLine />\n        <package name=\"com\" withSubpackages=\"true\" static=\"false\" />\n        <emptyLine />\n        <package name=\"gov\" withSubpackages=\"true\" static=\"false\" />\n        <emptyLine />\n        <package name=\"junit\" withSubpackages=\"true\" static=\"false\" />\n        <emptyLine />\n        <package name=\"net\" withSubpackages=\"true\" static=\"false\" />\n        <emptyLine />\n        <package name=\"org\" withSubpackages=\"true\" static=\"false\" />\n        <emptyLine />\n        <package name=\"java\" withSubpackages=\"true\" static=\"false\" />\n        <emptyLine />\n        <package name=\"javax\" withSubpackages=\"true\" static=\"false\" />\n        <emptyLine />\n        <package name=\"\" withSubpackages=\"true\" static=\"false\" />\n      </value>\n    </option>\n    <option name=\"JD_P_AT_EMPTY_LINES\" value=\"false\" />\n    <option name=\"JD_DO_NOT_WRAP_ONE_LINE_COMMENTS\" value=\"true\" />\n    <option name=\"JD_KEEP_EMPTY_PARAMETER\" value=\"false\" />\n    <option name=\"JD_KEEP_EMPTY_EXCEPTION\" value=\"false\" />\n    <option name=\"JD_KEEP_EMPTY_RETURN\" value=\"false\" />\n    <option name=\"JD_PRESERVE_LINE_FEEDS\" value=\"true\" />\n  </JavaCodeStyleSettings>\n  <JetCodeStyleSettings>\n    <option name=\"PACKAGES_TO_USE_STAR_IMPORTS\">\n      <value />\n    </option>\n    <option name=\"NAME_COUNT_TO_USE_STAR_IMPORT\" value=\"99\" />\n    <option name=\"NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS\" value=\"99\" />\n    <option name=\"IMPORT_NESTED_CLASSES\" value=\"true\" />\n    <option name=\"CONTINUATION_INDENT_IN_PARAMETER_LISTS\" value=\"false\" />\n    <option name=\"CONTINUATION_INDENT_IN_ARGUMENT_LISTS\" value=\"false\" />\n    <option name=\"CONTINUATION_INDENT_FOR_EXPRESSION_BODIES\" value=\"false\" />\n    <option name=\"CONTINUATION_INDENT_FOR_CHAINED_CALLS\" value=\"false\" />\n    <option name=\"CONTINUATION_INDENT_IN_SUPERTYPE_LISTS\" value=\"false\" />\n    <option name=\"CONTINUATION_INDENT_IN_IF_CONDITIONS\" value=\"false\" />\n    <option name=\"WRAP_EXPRESSION_BODY_FUNCTIONS\" value=\"1\" />\n    <option name=\"IF_RPAREN_ON_NEW_LINE\" value=\"true\" />\n  </JetCodeStyleSettings>\n  <Properties>\n    <option name=\"KEEP_BLANK_LINES\" value=\"true\" />\n  </Properties>\n  <XML>\n    <option name=\"XML_ATTRIBUTE_WRAP\" value=\"2\" />\n    <option name=\"XML_ALIGN_ATTRIBUTES\" value=\"false\" />\n    <option name=\"XML_SPACE_INSIDE_EMPTY_TAG\" value=\"true\" />\n    <option name=\"XML_LEGACY_SETTINGS_IMPORTED\" value=\"true\" />\n  </XML>\n  <ADDITIONAL_INDENT_OPTIONS fileType=\"java\">\n    <option name=\"TAB_SIZE\" value=\"8\" />\n  </ADDITIONAL_INDENT_OPTIONS>\n  <ADDITIONAL_INDENT_OPTIONS fileType=\"js\">\n    <option name=\"CONTINUATION_INDENT_SIZE\" value=\"4\" />\n  </ADDITIONAL_INDENT_OPTIONS>\n  <codeStyleSettings language=\"JAVA\">\n    <option name=\"ALIGN_MULTILINE_PARAMETERS\" value=\"false\" />\n    <option name=\"ALIGN_MULTILINE_FOR\" value=\"false\" />\n    <option name=\"CALL_PARAMETERS_WRAP\" value=\"1\" />\n    <option name=\"PREFER_PARAMETERS_WRAP\" value=\"true\" />\n    <option name=\"METHOD_PARAMETERS_WRAP\" value=\"1\" />\n    <option name=\"RESOURCE_LIST_WRAP\" value=\"1\" />\n    <option name=\"EXTENDS_LIST_WRAP\" value=\"1\" />\n    <option name=\"THROWS_LIST_WRAP\" value=\"1\" />\n    <option name=\"EXTENDS_KEYWORD_WRAP\" value=\"1\" />\n    <option name=\"THROWS_KEYWORD_WRAP\" value=\"1\" />\n    <option name=\"METHOD_CALL_CHAIN_WRAP\" value=\"1\" />\n    <option name=\"BINARY_OPERATION_WRAP\" value=\"1\" />\n    <option name=\"BINARY_OPERATION_SIGN_ON_NEXT_LINE\" value=\"true\" />\n    <option name=\"TERNARY_OPERATION_WRAP\" value=\"1\" />\n    <option name=\"TERNARY_OPERATION_SIGNS_ON_NEXT_LINE\" value=\"true\" />\n    <option name=\"FOR_STATEMENT_WRAP\" value=\"1\" />\n    <option name=\"ARRAY_INITIALIZER_WRAP\" value=\"1\" />\n    <option name=\"ASSIGNMENT_WRAP\" value=\"1\" />\n    <option name=\"IF_BRACE_FORCE\" value=\"3\" />\n    <option name=\"DOWHILE_BRACE_FORCE\" value=\"3\" />\n    <option name=\"WHILE_BRACE_FORCE\" value=\"3\" />\n    <option name=\"FOR_BRACE_FORCE\" value=\"3\" />\n    <option name=\"WRAP_LONG_LINES\" value=\"true\" />\n    <option name=\"PARAMETER_ANNOTATION_WRAP\" value=\"1\" />\n    <option name=\"VARIABLE_ANNOTATION_WRAP\" value=\"1\" />\n    <option name=\"ENUM_CONSTANTS_WRAP\" value=\"1\" />\n  </codeStyleSettings>\n  <codeStyleSettings language=\"JSON\">\n    <indentOptions>\n      <option name=\"CONTINUATION_INDENT_SIZE\" value=\"4\" />\n      <option name=\"TAB_SIZE\" value=\"2\" />\n    </indentOptions>\n  </codeStyleSettings>\n  <codeStyleSettings language=\"XML\">\n    <option name=\"FORCE_REARRANGE_MODE\" value=\"1\" />\n    <indentOptions>\n      <option name=\"CONTINUATION_INDENT_SIZE\" value=\"4\" />\n    </indentOptions>\n    <arrangement>\n      <rules>\n        <section>\n          <rule>\n            <match>\n              <AND>\n                <NAME>xmlns:android</NAME>\n                <XML_ATTRIBUTE />\n                <XML_NAMESPACE>^$</XML_NAMESPACE>\n              </AND>\n            </match>\n          </rule>\n        </section>\n        <section>\n          <rule>\n            <match>\n              <AND>\n                <NAME>xmlns:.*</NAME>\n                <XML_ATTRIBUTE />\n                <XML_NAMESPACE>^$</XML_NAMESPACE>\n              </AND>\n            </match>\n            <order>BY_NAME</order>\n          </rule>\n        </section>\n        <section>\n          <rule>\n            <match>\n              <AND>\n                <NAME>.*:id</NAME>\n                <XML_ATTRIBUTE />\n                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>\n              </AND>\n            </match>\n          </rule>\n        </section>\n        <section>\n          <rule>\n            <match>\n              <AND>\n                <NAME>.*:name</NAME>\n                <XML_ATTRIBUTE />\n                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>\n              </AND>\n            </match>\n          </rule>\n        </section>\n        <section>\n          <rule>\n            <match>\n              <AND>\n                <NAME>name</NAME>\n                <XML_ATTRIBUTE />\n                <XML_NAMESPACE>^$</XML_NAMESPACE>\n              </AND>\n            </match>\n          </rule>\n        </section>\n        <section>\n          <rule>\n            <match>\n              <AND>\n                <NAME>style</NAME>\n                <XML_ATTRIBUTE />\n                <XML_NAMESPACE>^$</XML_NAMESPACE>\n              </AND>\n            </match>\n          </rule>\n        </section>\n        <section>\n          <rule>\n            <match>\n              <AND>\n                <NAME>.*:layout_width</NAME>\n                <XML_ATTRIBUTE />\n                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>\n              </AND>\n            </match>\n          </rule>\n        </section>\n        <section>\n          <rule>\n            <match>\n              <AND>\n                <NAME>.*:layout_height</NAME>\n                <XML_ATTRIBUTE />\n                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>\n              </AND>\n            </match>\n          </rule>\n        </section>\n        <section>\n          <rule>\n            <match>\n              <AND>\n                <NAME>.*:layout_.*</NAME>\n                <XML_ATTRIBUTE />\n                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>\n              </AND>\n            </match>\n            <order>BY_NAME</order>\n          </rule>\n        </section>\n        <section>\n          <rule>\n            <match>\n              <AND>\n                <NAME>.*:width</NAME>\n                <XML_ATTRIBUTE />\n                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>\n              </AND>\n            </match>\n            <order>BY_NAME</order>\n          </rule>\n        </section>\n        <section>\n          <rule>\n            <match>\n              <AND>\n                <NAME>.*:height</NAME>\n                <XML_ATTRIBUTE />\n                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>\n              </AND>\n            </match>\n            <order>BY_NAME</order>\n          </rule>\n        </section>\n        <section>\n          <rule>\n            <match>\n              <AND>\n                <NAME>.*:viewportWidth</NAME>\n                <XML_ATTRIBUTE />\n                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>\n              </AND>\n            </match>\n          </rule>\n        </section>\n        <section>\n          <rule>\n            <match>\n              <AND>\n                <NAME>.*:viewportHeight</NAME>\n                <XML_ATTRIBUTE />\n                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>\n              </AND>\n            </match>\n          </rule>\n        </section>\n        <section>\n          <rule>\n            <match>\n              <AND>\n                <NAME>.*</NAME>\n                <XML_ATTRIBUTE />\n                <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>\n              </AND>\n            </match>\n            <order>BY_NAME</order>\n          </rule>\n        </section>\n        <section>\n          <rule>\n            <match>\n              <AND>\n                <NAME>.*:layout_.*</NAME>\n                <XML_ATTRIBUTE />\n                <XML_NAMESPACE>http://schemas.android.com/apk/res-auto</XML_NAMESPACE>\n              </AND>\n            </match>\n            <order>BY_NAME</order>\n          </rule>\n        </section>\n        <section>\n          <rule>\n            <match>\n              <AND>\n                <NAME>.*</NAME>\n                <XML_ATTRIBUTE />\n                <XML_NAMESPACE>http://schemas.android.com/apk/res-auto</XML_NAMESPACE>\n              </AND>\n            </match>\n            <order>BY_NAME</order>\n          </rule>\n        </section>\n        <section>\n          <rule>\n            <match>\n              <AND>\n                <NAME>.*:layout_.*</NAME>\n                <XML_ATTRIBUTE />\n                <XML_NAMESPACE>.*</XML_NAMESPACE>\n              </AND>\n            </match>\n            <order>BY_NAME</order>\n          </rule>\n        </section>\n        <section>\n          <rule>\n            <match>\n              <AND>\n                <NAME>.*</NAME>\n                <XML_ATTRIBUTE />\n                <XML_NAMESPACE>.*</XML_NAMESPACE>\n              </AND>\n            </match>\n            <order>BY_NAME</order>\n          </rule>\n        </section>\n      </rules>\n    </arrangement>\n  </codeStyleSettings>\n  <codeStyleSettings language=\"kotlin\">\n    <option name=\"KEEP_BLANK_LINES_IN_DECLARATIONS\" value=\"1\" />\n    <option name=\"KEEP_BLANK_LINES_IN_CODE\" value=\"1\" />\n    <option name=\"KEEP_BLANK_LINES_BEFORE_RBRACE\" value=\"0\" />\n    <option name=\"ALIGN_MULTILINE_PARAMETERS\" value=\"false\" />\n    <option name=\"CALL_PARAMETERS_WRAP\" value=\"1\" />\n    <option name=\"CALL_PARAMETERS_LPAREN_ON_NEXT_LINE\" value=\"true\" />\n    <option name=\"CALL_PARAMETERS_RPAREN_ON_NEXT_LINE\" value=\"true\" />\n    <option name=\"METHOD_PARAMETERS_WRAP\" value=\"5\" />\n    <option name=\"METHOD_PARAMETERS_LPAREN_ON_NEXT_LINE\" value=\"true\" />\n    <option name=\"METHOD_PARAMETERS_RPAREN_ON_NEXT_LINE\" value=\"true\" />\n    <option name=\"EXTENDS_LIST_WRAP\" value=\"1\" />\n    <option name=\"METHOD_CALL_CHAIN_WRAP\" value=\"1\" />\n    <option name=\"ASSIGNMENT_WRAP\" value=\"1\" />\n    <option name=\"FIELD_ANNOTATION_WRAP\" value=\"1\" />\n    <option name=\"PARAMETER_ANNOTATION_WRAP\" value=\"1\" />\n    <option name=\"VARIABLE_ANNOTATION_WRAP\" value=\"1\" />\n    <option name=\"ENUM_CONSTANTS_WRAP\" value=\"5\" />\n    <indentOptions>\n      <option name=\"CONTINUATION_INDENT_SIZE\" value=\"4\" />\n    </indentOptions>\n  </codeStyleSettings>\n</code_scheme>"
  },
  {
    "path": "tools/pre-push",
    "content": "#!/bin/bash\n#\n# Usage: Copy this file to your repository's .git/hooks directory. Name it\n# \"pre-push\" and set the executable bit.\n#\n# This hook aborts git push when the log message of any commits to be pushed\n# starts with \"WIP\" (work in progress) or \"stash\", if the remote ref begins\n# with \"refs/for/\". This prevents incomplete/unwanted commits from being\n# pushed to Gerrit instances.\n#\n# When pushing to Gerrit, this hook also runs a Gradle build similar to\n# Gerrit's presubmit, to help catch errors locally and reduce churn on CLs.\n#\n# Remaining comments are copied from .git/hooks/pre-push.sample.\n#\n# An example hook script to verify what is about to be pushed. Called by \"git\n# push\" after it has checked the remote status, but before anything has been\n# pushed. If this script exits with a non-zero status nothing will be pushed.\n#\n# This hook is called with the following parameters:\n#\n# $1 -- Name of the remote to which the push is being done\n# $2 -- URL to which the push is being done\n#\n# If pushing without using a named remote those arguments will be equal.\n#\n# Information about the commits which are being pushed is supplied as lines to\n# the standard input in the form:\n#\n#   <local ref> <local sha1> <remote ref> <remote sha1>\n\nremote=\"$1\"\nurl=\"$2\"\n\nz40=0000000000000000000000000000000000000000\n\nwhile read local_ref local_sha remote_ref remote_sha\ndo\n  # Handle delete\n  if [[ \"$local_sha\" = \"$z40\" ]]; then\n    echo \"$local_ref, $remote_ref\"\n    :\n  # Move along if we don't match refs/for/\n  elif [[ ! \"$remote_ref\" =~ ^refs/for/.+ ]]; then\n    echo \"$local_ref, $remote_ref\"\n    :\n  else\n    echo \"$local_ref, $local_sha, $remote_ref, $remote_sha\"\n    if [[ \"$remote_sha\" = \"$z40\" ]]; then\n      # New branch, examine all commits\n      branchname=\"${remote_ref#refs/for/}\"\n\n      if [[ \"$branchname\" =~ % ]]; then\n        # Gerrit allows various push-options by appending them to the remote\n        # using \"%<option>=<string>[,<option>=<string>...]\". Strip this off.\n        # TODO: '%' is a valid character in branch names, so this could mangle\n        # the branch name. We should find a way to workaround that.\n        echo \"NOTE: stripping Gerrit push-options beginning at '%'\"\n        branchname=${branchname%%\\%*}\n      fi\n\n      if git check-ref-format --allow-onelevel \"$branchname\"; then\n        range=\"${remote}/${branchname}..$local_sha\"\n      fi\n\n      if [[ -z \"$range\" ]]; then\n        range=\"$local_sha\"\n      fi\n    else\n      # Update to existing branch, examine new commits\n      range=\"$remote_sha..$local_sha\"\n    fi\n\n    echo \"Examining $range\"\n\n    # Check for WIP/stash commit\n    commit=`git rev-list -n 1 -i --grep '^WIP' --grep '^stash' \"$range\"`\n    if [[ -n \"$commit\" ]]; then\n      echo >&2 \"Found WIP/stash commit in $local_ref, not pushing ($commit)\"\n      exit 1\n    fi\n\n    # At least one ref is going to Gerrit, so we should run additional checks\n    run_checks=true\n  fi\ndone\n\nif [[ -n \"$run_checks\" ]]; then\n  # pre-push usually executes in the repository root, but just to be safe...\n  cd \"$(git rev-parse --show-toplevel)\"\n  ./gradlew check\n  exit $?\nfi\n\nexit 0\n"
  },
  {
    "path": "tools/setup.sh",
    "content": "#!/bin/bash\n\n# Copyright 2021 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     https://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nRED='\\033[0;1;31m'\nNC='\\033[0m' # No Color\n\nGIT_DIR=$(git rev-parse --git-dir 2> /dev/null)\nGIT_ROOT=$(git rev-parse --show-toplevel 2> /dev/null)\n\necho \"Installing git commit-message hook\"\necho\ncurl -sSLo \"${GIT_DIR}/hooks/commit-msg\" \\\n    \"https://gerrit-review.googlesource.com/tools/hooks/commit-msg\" \\\n  && chmod +x \"${GIT_DIR}/hooks/commit-msg\"\n\necho \"Installing git pre-push hook\"\necho\nmkdir -p \"${GIT_DIR}/hooks/\"\ncp \"${GIT_ROOT}/tools/pre-push\" \"${GIT_DIR}/hooks/pre-push\" \\\n  && chmod +x \"${GIT_DIR}/hooks/pre-push\"\n\ncat <<-EOF\nChecking the following settings helps avoid miscellaneous issues:\n  * Settings -> Editor -> General -> Remove trailing spaces on: Modified lines\n  * Settings -> Editor -> General -> Ensure every saved file ends with a line break\n  * Settings -> Editor -> General -> Auto Import -> Optimize imports on the fly (for both Kotlin\\\n and Java)\nEOF\n"
  },
  {
    "path": "ui-test-hilt-manifest/.gitignore",
    "content": "/build"
  },
  {
    "path": "ui-test-hilt-manifest/README.md",
    "content": "# `:ui-test-hilt-manifest`\n\n## Module dependency graph\n\n<!--region graph-->\n```mermaid\n---\nconfig:\n  layout: elk\n  elk:\n    nodePlacementStrategy: SIMPLE\n---\ngraph TB\n  :ui-test-hilt-manifest[ui-test-hilt-manifest]:::android-library\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n<details><summary>📋 Graph legend</summary>\n\n```mermaid\ngraph TB\n  application[application]:::android-application\n  feature[feature]:::android-feature\n  library[library]:::android-library\n  jvm[jvm]:::jvm-library\n\n  application -.-> feature\n  library --> jvm\n\nclassDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;\nclassDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;\n```\n\n</details>\n<!--endregion-->\n"
  },
  {
    "path": "ui-test-hilt-manifest/build.gradle.kts",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nplugins {\n    alias(libs.plugins.nowinandroid.android.library)\n    alias(libs.plugins.nowinandroid.hilt)\n}\n\nandroid {\n    namespace = \"com.google.samples.apps.nowinandroid.uitesthiltmanifest\"\n}\n"
  },
  {
    "path": "ui-test-hilt-manifest/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!--\n     Copyright 2022 The Android Open Source Project\n\n     Licensed under the Apache License, Version 2.0 (the \"License\");\n     you may not use this file except in compliance with the License.\n     You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n     Unless required by applicable law or agreed to in writing, software\n     distributed under the License is distributed on an \"AS IS\" BASIS,\n     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n     See the License for the specific language governing permissions and\n     limitations under the License.\n-->\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n\n    <application>\n        <!--\n        Use a no-action-bar theme to prevent overlapping with the action bar during tests.\n        Theme_Material_Light_NoActionBar is the base theme used by the production app.\n        -->\n        <activity\n            android:theme=\"@android:style/Theme.Material.Light.NoActionBar\"\n            android:name=\".HiltComponentActivity\"\n            />\n    </application>\n\n</manifest>\n"
  },
  {
    "path": "ui-test-hilt-manifest/src/main/kotlin/com/google/samples/apps/nowinandroid/uitesthiltmanifest/HiltComponentActivity.kt",
    "content": "/*\n * Copyright 2022 The Android Open Source Project\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     https://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\npackage com.google.samples.apps.nowinandroid.uitesthiltmanifest\n\nimport androidx.activity.ComponentActivity\nimport dagger.hilt.android.AndroidEntryPoint\n\n/**\n * A [ComponentActivity] annotated with [AndroidEntryPoint] for use in tests, as a workaround\n * for https://github.com/google/dagger/issues/3394\n */\n@AndroidEntryPoint\nclass HiltComponentActivity : ComponentActivity()\n"
  }
]