From 015b0a89fa64cd0a6c07b8d78d40e0a903797acf Mon Sep 17 00:00:00 2001 From: nhoxbypass Date: Sat, 18 Apr 2026 22:17:06 +0700 Subject: [PATCH 1/5] [Agent] Add Android rules - Add `.agents/rules/android-architecture.md` to enforce Clean Architecture and ViewModel independence from the Android framework. - Add `.agents/rules/android-tech-stack.md` to define the preferred stack, including Jetpack Compose, Kotlin Coroutines, Hilt, and Retrofit. - Add `.agents/rules/android-testing-quality.md` to require unit tests for business logic and mandate the use of string resources over hardcoded strings. --- .agents/rules/android-architecture.md | 8 ++++++++ .agents/rules/android-tech-stack.md | 11 +++++++++++ .agents/rules/android-testing-quality.md | 8 ++++++++ 3 files changed, 27 insertions(+) create mode 100644 .agents/rules/android-architecture.md create mode 100644 .agents/rules/android-tech-stack.md create mode 100644 .agents/rules/android-testing-quality.md diff --git a/.agents/rules/android-architecture.md b/.agents/rules/android-architecture.md new file mode 100644 index 00000000..481eb3a3 --- /dev/null +++ b/.agents/rules/android-architecture.md @@ -0,0 +1,8 @@ +--- +trigger: always_on +--- + +## Android: Architecture + +- Follow Clean Architecture and Google's official architecture for Android apps +- ViewModels must NOT contain any Android framework references (no `Context`, `View`, etc.) \ No newline at end of file diff --git a/.agents/rules/android-tech-stack.md b/.agents/rules/android-tech-stack.md new file mode 100644 index 00000000..ef07c843 --- /dev/null +++ b/.agents/rules/android-tech-stack.md @@ -0,0 +1,11 @@ +--- +trigger: always_on +--- + +## Android: Tech Stack & Libraries + +- Language: Kotlin. Use idiomatic Kotlin features (extension functions, scoped functions, data classes, sealed interfaces) +- UI Toolkit: Jetpack Compose. DO NOT use XML layouts, Fragments, or ViewBinding. +- Asynchronous Programming: Kotlin Coroutines and StateFlow/SharedFlow. DO NOT use RxJava or LiveData. +- Dependency Injection: Hilt. +- Networking: Retrofit with OkHttp and Kotlinx Serialization. \ No newline at end of file diff --git a/.agents/rules/android-testing-quality.md b/.agents/rules/android-testing-quality.md new file mode 100644 index 00000000..97434136 --- /dev/null +++ b/.agents/rules/android-testing-quality.md @@ -0,0 +1,8 @@ +--- +trigger: always_on +--- + +## Android: Testing & Quality + +- Write unit tests for all business logic code and view models +- Hardcoded strings are strictly forbidden. Always extract to `res/values/strings.xml`. \ No newline at end of file From 2fc0930ad70ffbea28a05025cf0b8cd9098045af Mon Sep 17 00:00:00 2001 From: nhoxbypass Date: Sun, 19 Apr 2026 11:32:04 +0700 Subject: [PATCH 2/5] [Agent] Add Android skills --- .../skills/build/agp/agp-9-upgrade/SKILL.md | 102 ++ .../build/migrate-to-built-in-kotlin.md | 416 ++++++ .../build/releases/agp-9-0-0-release-notes.md | 551 ++++++++ .../agp-9-upgrade/references/buildconfig.md | 54 + .../agp/agp-9-upgrade/references/ksp-kapt.md | 39 + .../references/paparazzi-gradle-9.md | 30 + .../agp/agp-9-upgrade/references/recipes.md | 63 + .../SKILL.md | 123 ++ .../analysis-of-the-project-and-layout.md | 42 + .../migrate-xml-theme-to-compose.md | 168 +++ .../interoperability-apis/compose-in-views.md | 293 +++++ .../interoperability-apis/views-in-compose.md | 275 ++++ ...setup-compose-dependencies-and-compiler.md | 186 +++ .../identify-optimal-xml-candidate.md | 31 + .../references/xml-layout-migration.md | 75 ++ .../skills/navigation/navigation-3/SKILL.md | 109 ++ .../guide/navigation/navigation-3/index.md | 38 + .../navigation-3/migration-guide.md | 495 +++++++ .../navigation-3/recipes/animations.md | 147 +++ .../navigation/navigation-3/recipes/basic.md | 89 ++ .../navigation-3/recipes/basicdsl.md | 85 ++ .../navigation-3/recipes/basicsaveable.md | 90 ++ .../navigation-3/recipes/bottomsheet.md | 195 +++ .../navigation-3/recipes/common-ui.md | 200 +++ .../navigation-3/recipes/conditional.md | 228 ++++ .../recipes/deeplinks-advanced.md | 153 +++ .../navigation-3/recipes/deeplinks-basic.md | 740 +++++++++++ .../navigation/navigation-3/recipes/dialog.md | 107 ++ .../recipes/material-listdetail.md | 141 ++ .../recipes/material-supportingpane.md | 145 ++ .../navigation-3/recipes/modular-hilt.md | 283 ++++ .../navigation-3/recipes/modular-koin.md | 287 ++++ .../recipes/multiple-backstacks.md | 436 ++++++ .../navigation-3/recipes/passingarguments.md | 376 ++++++ .../navigation-3/recipes/results-event.md | 400 ++++++ .../navigation-3/recipes/results-state.md | 369 ++++++ .../navigation-3/recipes/scenes-listdetail.md | 435 ++++++ .../navigation-3/recipes/scenes-twopane.md | 245 ++++ .../navigation/type-safe-destinations.md | 129 ++ .../skills/performance/r8-analyzer/SKILL.md | 41 + .../r8-analyzer/references/CONFIGURATION.md | 44 + .../references/KEEP-RULES-IMPACT-HIERARCHY.md | 83 ++ .../r8-analyzer/references/REDUNDANT-RULES.md | 222 ++++ .../references/REFLECTION-GUIDE.md | 139 ++ .../enable-app-optimization.md | 173 +++ .../SKILL.md | 92 ++ .../google/play/billing/release-notes.md | 1171 +++++++++++++++++ .../references/migration-logic.md | 76 ++ .../references/version-checklist.md | 46 + .agents/skills/system/edge-to-edge/SKILL.md | 425 ++++++ 50 files changed, 10882 insertions(+) create mode 100644 .agents/skills/build/agp/agp-9-upgrade/SKILL.md create mode 100644 .agents/skills/build/agp/agp-9-upgrade/references/android/build/migrate-to-built-in-kotlin.md create mode 100644 .agents/skills/build/agp/agp-9-upgrade/references/android/build/releases/agp-9-0-0-release-notes.md create mode 100644 .agents/skills/build/agp/agp-9-upgrade/references/buildconfig.md create mode 100644 .agents/skills/build/agp/agp-9-upgrade/references/ksp-kapt.md create mode 100644 .agents/skills/build/agp/agp-9-upgrade/references/paparazzi-gradle-9.md create mode 100644 .agents/skills/build/agp/agp-9-upgrade/references/recipes.md create mode 100644 .agents/skills/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/SKILL.md create mode 100644 .agents/skills/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/analysis-of-the-project-and-layout.md create mode 100644 .agents/skills/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/designsystems/migrate-xml-theme-to-compose.md create mode 100644 .agents/skills/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/migrate/interoperability-apis/compose-in-views.md create mode 100644 .agents/skills/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/migrate/interoperability-apis/views-in-compose.md create mode 100644 .agents/skills/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/setup-compose-dependencies-and-compiler.md create mode 100644 .agents/skills/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/identify-optimal-xml-candidate.md create mode 100644 .agents/skills/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/xml-layout-migration.md create mode 100644 .agents/skills/navigation/navigation-3/SKILL.md create mode 100644 .agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/index.md create mode 100644 .agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/migration-guide.md create mode 100644 .agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/animations.md create mode 100644 .agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/basic.md create mode 100644 .agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/basicdsl.md create mode 100644 .agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/basicsaveable.md create mode 100644 .agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/bottomsheet.md create mode 100644 .agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/common-ui.md create mode 100644 .agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/conditional.md create mode 100644 .agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-advanced.md create mode 100644 .agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-basic.md create mode 100644 .agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/dialog.md create mode 100644 .agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/material-listdetail.md create mode 100644 .agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/material-supportingpane.md create mode 100644 .agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/modular-hilt.md create mode 100644 .agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/modular-koin.md create mode 100644 .agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/multiple-backstacks.md create mode 100644 .agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/passingarguments.md create mode 100644 .agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/results-event.md create mode 100644 .agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/results-state.md create mode 100644 .agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/scenes-listdetail.md create mode 100644 .agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/scenes-twopane.md create mode 100644 .agents/skills/navigation/navigation-3/references/android/guide/navigation/type-safe-destinations.md create mode 100644 .agents/skills/performance/r8-analyzer/SKILL.md create mode 100644 .agents/skills/performance/r8-analyzer/references/CONFIGURATION.md create mode 100644 .agents/skills/performance/r8-analyzer/references/KEEP-RULES-IMPACT-HIERARCHY.md create mode 100644 .agents/skills/performance/r8-analyzer/references/REDUNDANT-RULES.md create mode 100644 .agents/skills/performance/r8-analyzer/references/REFLECTION-GUIDE.md create mode 100644 .agents/skills/performance/r8-analyzer/references/android/topic/performance/app-optimization/enable-app-optimization.md create mode 100644 .agents/skills/play/play-billing-library-version-upgrade/SKILL.md create mode 100644 .agents/skills/play/play-billing-library-version-upgrade/references/android/google/play/billing/release-notes.md create mode 100644 .agents/skills/play/play-billing-library-version-upgrade/references/migration-logic.md create mode 100644 .agents/skills/play/play-billing-library-version-upgrade/references/version-checklist.md create mode 100644 .agents/skills/system/edge-to-edge/SKILL.md diff --git a/.agents/skills/build/agp/agp-9-upgrade/SKILL.md b/.agents/skills/build/agp/agp-9-upgrade/SKILL.md new file mode 100644 index 00000000..6436b336 --- /dev/null +++ b/.agents/skills/build/agp/agp-9-upgrade/SKILL.md @@ -0,0 +1,102 @@ +--- +name: agp-9-upgrade +description: Upgrades, or migrates, an Android project to use Android Gradle Plugin + (AGP) version 9. Do not use this skill for migrating Kotlin Multiplatform (KMP) projects. +license: Complete terms in LICENSE.txt +metadata: + author: Google LLC + keywords: + - Android Gradle Plugin 9 + - AGP 9 + - AGP Upgrade + - AGP Migration + - New AGP DSL + - Migrate to built-in Kotlin +--- + +## Migration guide + +See the [AGP 9 migration guide](references/android/build/releases/agp-9-0-0-release-notes.md) for the major changes, many +breaking, in AGP 9 compared to AGP 8. + +## Requirements + +If the user requests to update or migrate to AGP 9, first check the AGP version +used in the project. If it is lower than 9, stop and ask the user to run the AGP +Upgrade Assistant in Android Studio to update to the latest stable version of +AGP, and confirm when done. The user may also request that this requirement be +skipped; if this is the case, you should update the version of AGP to the latest +stable version as part of the AGP 9 migration. See the +[AGP 9 migration guide](references/android/build/releases/agp-9-0-0-release-notes.md) +for how to do this. + +Each version of AGP has its own set of compatibilities with other tools, such as +Gradle, JDK, and Kotlin. The release notes for each of these versions will +include a **Compatibility** table indicating the minimum versions for these +tools. + +Do not use this skill for KMP projects, as they are unsupported. + +## Steps + +If AGP is already at 9 or higher, then do the following: + +### Step 1: Update dependencies + +If KSP (`com.google.devtools.ksp`) is used in the project, ensure it is on +version 2.3.6 or higher. + +If Hilt is used in the project, ensure it is on version 2.59.2 or higher. + +### Step 2: Migrate to built-in Kotlin. + +See [the guide](references/android/build/migrate-to-built-in-kotlin.md) for detailed information. + +### Step 3. Migrate to the new AGP DSL. + +See [the guide](references/android/build/releases/agp-9-0-0-release-notes.md) for detailed information. + +See also [gradle-recipes](references/recipes.md) for examples on how to migrate old code to code +that is compatible with AGP 9 and the new DSL. + +### Step 4. Migrate kapt to KSP or legacy-kapt + +If KSP (`com.google.devtools.ksp`) or kapt (`org.jetbrains.kotlin.kapt`) are +used in the project, [KSP, kapt, and legacy-kapt](references/ksp-kapt.md) +for detailed migration steps. + +### Step 5. BuildConfig + +If any Android module contains custom BuildConfig fields, see [BuildConfig](references/buildconfig.md) +for detailed information. + +### Step 6. Update gradle.properties + +After the migration, check gradle.properties. Remove the following flags: + +1. android.builtInKotlin +2. android.newDsl +3. android.uniquePackageNames +4. android.enableAppCompileTimeRClass + +Additionally, delete all temporary files you've created. + +## Guidelines + +- Never write or run python scripts. +- Only search the Gradle dependency cache when inspecting external dependencies, and only as a last resort. +- Never add `android.disallowKotlinSourceSets=false` to `gradle.properties`. +- When verifying changes, don't run the `clean` task. This is a waste of time. + +## Verification + +After migration, verify the following: + +1. Gradle IDE sync succeeds. +2. `./gradlew help` succeeds. +3. `./gradlew build --dry-run` succeeds. + +## Troubleshooting + +Paparazzi v2.0.0-alpha04 and lower versions have issues with AGP 9. See +[references/paparazzi-gradle-9.md](references/paparazzi-gradle-9.md) for details. \ No newline at end of file diff --git a/.agents/skills/build/agp/agp-9-upgrade/references/android/build/migrate-to-built-in-kotlin.md b/.agents/skills/build/agp/agp-9-upgrade/references/android/build/migrate-to-built-in-kotlin.md new file mode 100644 index 00000000..7306e7be --- /dev/null +++ b/.agents/skills/build/agp/agp-9-upgrade/references/android/build/migrate-to-built-in-kotlin.md @@ -0,0 +1,416 @@ +Android Gradle plugin 9.0 introduces built-in Kotlin support and enables it +by default. That means you no longer have to apply the +`org.jetbrains.kotlin.android` (or `kotlin-android`) plugin in your build files +to compile Kotlin source files. +With built-in Kotlin, your build files are simpler and you can avoid +compatibility issues between AGP and the `kotlin-android` plugin. + +> [!NOTE] +> **Note:** Built-in Kotlin replaces the `kotlin-android` plugin only. If you are writing a Kotlin Multiplatform (KMP) library module, you still need to apply the `org.jetbrains.kotlin.multiplatform` plugin and the [`com.android.kotlin.multiplatform.library`](https://developer.android.com/kotlin/multiplatform/plugin) plugin. Also, using the `org.jetbrains.kotlin.multiplatform` plugin together with the `com.android.library` or `com.android.application` plugin is no longer allowed when built-in Kotlin is enabled. + +## Enable built-in Kotlin + +You need AGP 9.0 or higher to have built-in Kotlin support. +AGP 9.0 already enables built-in Kotlin for all your modules where you apply +AGP, so you don't need to do anything to enable it. However, if you previously +[opted out of built-in Kotlin](https://developer.android.com/build/migrate-to-built-in-kotlin#opt-out-of-built-in-kotlin) by setting `android.builtInKotlin=false` +in the `gradle.properties` file, you need to remove that setting or set it to +`true`. + +> [!NOTE] +> **Note:** You can also enable built-in Kotlin for [one module at a time](https://developer.android.com/build/migrate-to-built-in-kotlin#module-by-module-migration). + +Built-in Kotlin requires some changes to your project, so after you +have built-in Kotlin enabled, follow the next steps to migrate your project. + +## Migration steps + +After you upgrade your project from an older AGP version to AGP 9.0 or after +you manually [enable built-in Kotlin](https://developer.android.com/build/migrate-to-built-in-kotlin#enable-built-in-kotlin), you might see the following error +message: + + Failed to apply plugin 'org.jetbrains.kotlin.android'. + > Cannot add extension with name 'kotlin', as there is an extension already registered with that name. + +...or + + Failed to apply plugin 'com.jetbrains.kotlin.android' + > The 'org.jetbrains.kotlin.android' plugin is no longer required for Kotlin support since AGP 9.0. + +This error occurs because built-in Kotlin requires some changes to your project. +To resolve this error, follow these steps: + +> [!NOTE] +> **Note:** If you're not yet ready to migrate your project, you can also [opt out of built-in Kotlin](https://developer.android.com/build/migrate-to-built-in-kotlin#opt-out-of-built-in-kotlin). + +1. [Remove the `kotlin-android` plugin](https://developer.android.com/build/migrate-to-built-in-kotlin#migration-steps-remove-kotlin-android-plugin) +2. [Migrate the `kotlin-kapt` plugin if necessary](https://developer.android.com/build/migrate-to-built-in-kotlin#migration-steps-migrate-kotlin-kapt-plugin) +3. [Migrate the `android.kotlinOptions{}` DSL if necessary](https://developer.android.com/build/migrate-to-built-in-kotlin#migration-steps-migrate-kotlin-options) +4. [Migrate the `kotlin.sourceSets{}` DSL if necessary](https://developer.android.com/build/migrate-to-built-in-kotlin#migration-steps-migrate-kotlin-source-sets) + +### 1. Remove the `kotlin-android` plugin + +Remove the `org.jetbrains.kotlin.android` (or `kotlin-android`) plugin from +the module-level build files where you apply it. +The exact code to remove depends on +whether you use [version catalogs](https://docs.gradle.org/current/userguide/version_catalogs.html) to declare plugins. + +### With version catalogs + +### Kotlin + +```kotlin +// Module-level build file +plugins { + alias(libs.plugins.kotlin.android) +} +``` + +### Groovy + +```groovy +// Module-level build file +plugins { + alias(libs.plugins.kotlin.android) +} +``` + +### No version catalogs + +### Kotlin + +```kotlin +// Module-level build file +plugins { + id("org.jetbrains.kotlin.android") +} +``` + +### Groovy + +```groovy +// Module-level build file +plugins { + id 'org.jetbrains.kotlin.android' +} +``` + +Then, remove the plugin from your top-level build file: + +### With version catalogs + +### Kotlin + +```kotlin +// Top-level build file +plugins { + alias(libs.plugins.kotlin.android) apply false +} +``` + +### Groovy + +```groovy +// Top-level build file +plugins { + alias(libs.plugins.kotlin.android) apply false +} +``` + +### No version catalogs + +### Kotlin + +```kotlin +// Top-level build file +plugins { + id("org.jetbrains.kotlin.android") version "KOTLIN_VERSION" apply false +} +``` + +### Groovy + +```groovy +// Top-level build file +plugins { + id 'org.jetbrains.kotlin.android' version 'KOTLIN_VERSION' apply false +} +``` + +If you use version catalogs, also remove the plugin definition from the +version catalog TOML file (usually `gradle/libs.versions.toml`): + +```toml +[plugins] +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "KOTLIN_VERSION" } +``` + +### 2. Migrate the `kotlin-kapt` plugin if necessary + +The `org.jetbrains.kotlin.kapt` (or `kotlin-kapt`) plugin is incompatible with +built-in Kotlin. If you use `kapt`, we recommend that you +[migrate your project to KSP](https://developer.android.com/build/migrate-to-ksp). + +If you can't migrate to KSP yet, replace the `kotlin-kapt` plugin with the +`com.android.legacy-kapt` plugin, using the same version as your Android Gradle +plugin. + +For example, with version catalogs, update your version catalog TOML +file as follows: + +```toml +[plugins] +android-application = { id = "com.android.application", version.ref = "AGP_VERSION" } + +# Add the following plugin definition +legacy-kapt = { id = "com.android.legacy-kapt", version.ref = "AGP_VERSION" } + +# Remove the following plugin definition +kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "KOTLIN_VERSION" } +``` + +Then, update your build files: + +### Kotlin + +```kotlin +// Top-level build file +plugins { + alias(libs.plugins.legacy.kapt) apply false + alias(libs.plugins.kotlin.kapt) apply false +} +``` + +### Groovy + +```groovy +// Top-level build file +plugins { + alias(libs.plugins.legacy.kapt) apply false + alias(libs.plugins.kotlin.kapt) apply false +} +``` + +### Kotlin + +```kotlin +// Module-level build file +plugins { + alias(libs.plugins.legacy.kapt) + alias(libs.plugins.kotlin.kapt) +} +``` + +### Groovy + +```groovy +// Module-level build file +plugins { + alias(libs.plugins.legacy.kapt) + alias(libs.plugins.kotlin.kapt) +} +``` + +> [!NOTE] +> **Note:** If you declare the `kotlin-kapt` plugin in the `plugins{}` block as `kotlin("kapt") version ""`, then remove that line instead. + +### 3. Migrate the `android.kotlinOptions{}` DSL if necessary + +If you use the `android.kotlinOptions{}` DSL, you need to +migrate it to the [`kotlin.compilerOptions{}`](https://kotlinlang.org/docs/gradle-compiler-options.html#migrate-from-kotlinoptions-to-compileroptions) DSL. + +For example, update this code: + +### Kotlin + +```kotlin +android { + kotlinOptions { + languageVersion = "2.0" + jvmTarget = "11" + } +} +``` + +### Groovy + +```groovy +android { + kotlinOptions { + languageVersion = "2.0" + jvmTarget = "11" + } +} +``` + +...to the new DSL: + +### Kotlin + +```kotlin +kotlin { + compilerOptions { + languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_0 + // Optional: Set jvmTarget + // jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 + } +} +``` + +### Groovy + +```groovy +kotlin { + compilerOptions { + languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_0 + // Optional: Set jvmTarget + // jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 + } +} +``` + +> [!NOTE] +> **Note:** With built-in Kotlin, you don't need to set `kotlin.compilerOptions.jvmTarget` because its value defaults to `android.compileOptions.targetCompatibility`. + +### 4. Migrate the `kotlin.sourceSets{}` DSL if necessary + +When you use the `kotlin-android` plugin, AGP lets you add additional Kotlin +source directories using either the [`android.sourceSets{}`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/dsl/AndroidSourceSet) DSL or the +[`kotlin.sourceSets{}`](https://kotlinlang.org/api/kotlin-gradle-plugin/kotlin-gradle-plugin-api/org.jetbrains.kotlin.gradle.plugin/-kotlin-source-set/) DSL. +With the `android.sourceSets{}` DSL, you can add the directories to either the +`AndroidSourceSet.kotlin` set or the `AndroidSourceSet.java` set. + +With built-in Kotlin, the only supported option is to add the directories to the +`AndroidSourceSet.kotlin` set using the `android.sourceSets{}` DSL. +If you use unsupported options, migrate them as follows: + +### Kotlin + +```kotlin +# Adding Kotlin source directories to kotlin.sourceSets is not supported +kotlin.sourceSets.named("main") { + kotlin.srcDir("additionalSourceDirectory/kotlin") +} + +# Adding Kotlin source directories to AndroidSourceSet.java is also not supported +android.sourceSets.named("main") { + java.directories += "additionalSourceDirectory/kotlin" +} + +# Add Kotlin source directories to AndroidSourceSet.kotlin +android.sourceSets.named("main") { + kotlin.directories += "additionalSourceDirectory/kotlin" +} +``` + +### Groovy + +```groovy +# Adding Kotlin source directories to kotlin.sourceSets is not supported +kotlin.sourceSets.named("main") { + kotlin.srcDir("additionalSourceDirectory/kotlin") +} + +# Adding Kotlin source directories to AndroidSourceSet.java is also not supported +android.sourceSets.named("main") { + java.directories.add("additionalSourceDirectory/kotlin") +} + +# Add Kotlin source directories to AndroidSourceSet.kotlin +android.sourceSets.named("main") { + kotlin.directories.add("additionalSourceDirectory/kotlin") +} +``` + +If you want to add a Kotlin source directory to a specific variant or if the +directory is generated by a task, you can use the +[`addStaticSourceDirectory`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/variant/SourceDirectories#addStaticSourceDirectory(kotlin.String)) or [`addGeneratedSourceDirectory`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/variant/SourceDirectories#addGeneratedSourceDirectory(org.gradle.api.tasks.TaskProvider,kotlin.Function1)) methods +in the [variant API](https://developer.android.com/build/extend-agp#variant-api-artifacts-tasks): + +### Kotlin + +```kotlin +androidComponents.onVariants { variant -> + variant.sources.kotlin!!.addStaticSourceDirectory("additionalSourceDirectory/kotlin") + variant.sources.kotlin!!.addGeneratedSourceDirectory(TASK_PROVIDER, TASK_OUTPUT) +} +``` + +### Groovy + +```groovy +androidComponents.onVariants { variant -> + variant.sources.kotlin!!.addStaticSourceDirectory("additionalSourceDirectory/kotlin") + variant.sources.kotlin!!.addGeneratedSourceDirectory(TASK_PROVIDER, TASK_OUTPUT) +} +``` + +## Report issues + +If you encounter issues after completing the previous steps, +review the known issues in [issue #438678642](https://issuetracker.google.com/438678642) and give us +feedback if needed. + +## Opt out of built-in Kotlin + +If you are unable to migrate your project to use built-in Kotlin, set +`android.builtInKotlin=false` in the `gradle.properties` file to temporarily +disable it. +When you do that, the build shows a warning reminding you to migrate to built-in +Kotlin as you won't be able to disable built-in Kotlin in AGP 10.0. + +> [!NOTE] +> **Note:** You also need to set `android.newDsl=false` to opt out of the [new DSL](https://developer.android.com/r/tools/new-dsl) because the `kotlin-android` plugin is not compatible with it. + +Once you're ready to migrate your project, [enable built-in Kotlin](https://developer.android.com/build/migrate-to-built-in-kotlin#enable-built-in-kotlin) +and follow the [migration steps](https://developer.android.com/build/migrate-to-built-in-kotlin#migration-steps). + +## Module-by-module migration + +The `android.builtInKotlin` Gradle property lets you enable or disable built-in +Kotlin for all your modules where you apply AGP. + +If migrating all your modules at once is challenging, you can migrate one module +at a time: + +1. Set `android.builtInKotlin=false` in the `gradle.properties` file to + disable built-in Kotlin for all modules. + +2. Apply the `com.android.built-in-kotlin` plugin to the module + you want to enable built-in Kotlin, using the same version as your + Android Gradle plugin. + +3. Follow the previous [migration steps](https://developer.android.com/build/migrate-to-built-in-kotlin#migration-steps) to migrate this module to + built-in Kotlin. + +4. Once you've migrated all your modules, remove the + `android.builtInKotlin=false` setting in `gradle.properties` + and the `com.android.built-in-kotlin` plugin in your build files. + +## Option to selectively disable built-in Kotlin + +Android Gradle plugin 9.0 enables built-in Kotlin for all modules where it is +applied. +We recommend disabling built-in Kotlin selectively for modules that don't have +Kotlin sources in large projects. +This removes both the Kotlin compilation task, which has a small build +performance cost, and the automatic dependency on the Kotlin standard library. + +To disable built-in Kotlin for a module, +set `enableKotlin = false` in that module's build file: + +### Kotlin + +```kotlin +android { + enableKotlin = false +} +``` + +### Groovy + +```groovy +android { + enableKotlin = false +} +``` \ No newline at end of file diff --git a/.agents/skills/build/agp/agp-9-upgrade/references/android/build/releases/agp-9-0-0-release-notes.md b/.agents/skills/build/agp/agp-9-upgrade/references/android/build/releases/agp-9-0-0-release-notes.md new file mode 100644 index 00000000..b6a6e567 --- /dev/null +++ b/.agents/skills/build/agp/agp-9-upgrade/references/android/build/releases/agp-9-0-0-release-notes.md @@ -0,0 +1,551 @@ +
+ +Android Gradle plugin 9.0 is a major release that brings API and behavior +changes. + +To update to Android Gradle plugin 9.0.1, +use the [Android Gradle plugin Upgrade Assistant](https://developer.android.com/build/agp-upgrade-assistant). + +The AGP upgrade assistant helps preserve existing behaviors when upgrading your +project whenever appropriate, allowing you to upgrade your project to use AGP +9.0 even if you're not ready to adopt all the new defaults in AGP 9.0. + +## Compatibility + +The maximum API level that Android Gradle plugin 9.0 supports is API level 36.1. +Here is other compatibility info: + + +| | Minimum version | Default version | Notes | +|---:|:---:|:---:|:---:| +| Gradle | 9.1.0 | 9.1.0 | To learn more, see [updating Gradle](https://developer.android.com/build/releases/gradle-plugin?buildsystem=ndk-build#updating-gradle). | +| SDK Build Tools | 36.0.0 | 36.0.0 | [Install](https://developer.android.com/studio/intro/update#sdk-manager) or [configure](https://developer.android.com/tools/releases/build-tools) SDK Build Tools. | +| NDK | N/A | 28.2.13676358 | [Install](https://developer.android.com/studio/projects/install-ndk#specific-version) or [configure](https://developer.android.com/studio/projects/install-ndk#apply-specific-version) a different version of the NDK. | +| JDK | 17 | 17 | To learn more, see [setting the JDK version](https://developer.android.com/studio/intro/studio-config#jdk). | + +
+ +## The `android` DSL classes now only implement the new public interfaces + +Over the last several years, we have introduced +[new interfaces](https://developer.android.com/reference/tools/gradle-api) for our DSL and API in order to +better control which APIs are public. AGP versions +7.x and 8.x still used the old DSL types (for example `BaseExtension`) which +also implemented the new public interfaces, in order to maintain compatibility +as work progressed on the interfaces. + +AGP 9.0 uses our new DSL interfaces exclusively, +and the implementations have changed to new types that are fully hidden. This +also removes access to the old, deprecated variant API. + +To update to AGP 9.0, you might need to do the following: + +- **Ensure your project is compatible with [built-in + Kotlin](https://developer.android.com/build/releases/agp-9-0-0-release-notes#android-gradle-plugin-built-in-kotlin):** The `org.jetbrains.kotlin.android` plugin is not compatible with the new DSL. +- **Switch KMP projects to the + [Android Gradle Library Plugin for KMP](https://developer.android.com/kotlin/multiplatform/plugin):** + Using the `org.jetbrains.kotlin.multiplatform` plugin in the same Gradle + subproject as the `com.android.library` and `com.android.application` plugins + is not compatible with the new DSL. + + > [!NOTE] + > **Note:** The new KMP integration does not support using KMP and the Android Application plugin in the same Gradle subproject. To migrate, extract your Android app to a separate subproject. + +- **Update your build files:** + While the change of interfaces is meant to keep + the DSL as similar as possible, there might be + [some small changes](https://developer.android.com/build/releases/agp-9-0-0-release-notes#android-gradle-plugin-changed-dsl). + +- **Update your custom build logic to reference the new DSL and API:** + Replace any references to the internal DSL with the public DSL interfaces. + In most cases this will be a one-to-one replacement. + Replace any use of the `applicationVariants` and similar APIs with the new + [`androidComponents` API](https://developer.android.com/build/extend-agp#variant-api-artifacts-tasks). + This might be more complex, as the `androidComponents` API + is designed to be more stable to keep plugins compatible longer. Check our + [Gradle Recipes](https://github.com/android/gradle-recipes/tree/agp-9.0) + for examples. + +- **Update third-party plugins:** + Some third-party plugins might still depend on interfaces or APIs that are + no longer exposed. Migrate to versions of those plugins which are compatible + with AGP 9.0. + +The switch to the new DSL interfaces prevents plugins and Gradle build scripts +using various deprecated APIs, including: + +| Deprecated API in the `android` block | Function | Replacement | +|---|---|---| +| `applicationVariants`, `libraryVariants`, `testVariants`, and `unitTestVariants` | Extension points for plugins to add new functionality to AGP. | Replace this with the [`androidComponents.onVariants`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/variant/AndroidComponentsExtension#onVariants(com.android.build.api.variant.VariantSelector,kotlin.Function1)) API, for example: ```kotlin androidComponents { onVariants() { variant -> variant.signingConfig .enableV1Signing.set(false) } } ``` There might not be a direct replacement for all previous APIs. [File an issue](https://developer.android.com/studio/report-bugs) if there is a use case that is not covered by the new variant APIs. | +| `variantFilter` | Allows selected variants to be disabled. | Replace this with the [`androidComponents.beforeVariants`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/variant/AndroidComponentsExtension#beforeVariants(com.android.build.api.variant.VariantSelector,kotlin.Function1))) API, for example: ```kotlin androidComponents { beforeVariants( selector() .withBuildType("debug") .withFlavor("color", "blue") ) { variantBuilder -> variantBuilder.enable = false } } ``` | +| `deviceProvider` and `testServer` | Registration of custom test environments for running tests against Android devices and emulators. | Switch to [Gradle-managed devices](https://developer.android.com/studio/test/gradle-managed-devices). | +| `sdkDirectory`, `ndkDirectory`, `bootClasspath`, `adbExecutable`, and `adbExe` | Using various components of the Android SDK for custom tasks. | Switch to [`androidComponents.sdkComponents`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/dsl/SdkComponents). | +| `registerArtifactType`, `registerBuildTypeSourceProvider`, `registerProductFlavorSourceProvider`, `registerJavaArtifact`, `registerMultiFlavorSourceProvider`, and `wrapJavaSourceSet` | Obsolete functionality mostly related to the handling of generated sources in Android Studio, which stopped working in AGP 7.2.0. | There is no direct replacement for these APIs. | +| `dexOptions` | Obsolete settings related to the `dx` tool, which has been replaced by [`d8`](https://developer.android.com/tools/d8). None of the settings have had any effect since Android Gradle plugin 7.0. | There is no direct replacement. | +| `generatePureSplits` | Generate configuration splits for instant apps. | The ability to ship configuration splits is now built in to Android app bundles. | +| `aidlPackagedList` | AIDL files to package in the AAR to expose it as API for libraries and apps that depend on this library. | This is still exposed on [`LibraryExtension`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/dsl/LibraryExtension) but not on the other extension types. | + +If you update to AGP 9.0 and see the following error message, it means that +your project is still referencing some of the old types: + + java.lang.ClassCastException: class com.android.build.gradle.internal.dsl.ApplicationExtensionImpl$AgpDecorated_Decorated + cannot be cast to class com.android.build.gradle.BaseExtension + +If you are blocked by incompatible third-party plugins, you can opt out and +get back the old implementations for the DSL, as well as the old variant API. +While doing this, the new interfaces are also available, and you can still +update your own build logic to the new API. To opt out, include this line in +your `gradle.properties` file: + + android.newDsl=false + +The previous classes are marked as deprecated in AGP 9.0. This means projects +that opt out of the `newDsl` flag will see deprecation warnings, including on +the `android` block itself. + +> [!CAUTION] +> **Caution:** The ability to opt-out will be removed in AGP 10.0 (mid-2026). + +You can also start upgrading to the new APIs before upgrading to AGP 9.0. The +new interfaces have been present for many AGP versions and so you can have a mix +of new and old. The [AGP API reference docs](https://developer.android.com/reference/tools/gradle-api) show the API +surface for each AGP version, and when each class, method and field was +added. + +We're reaching out to the authors of commonly used plugins to help them adapt +and release plugins that are fully compatible with the new modes, and will +continue to enhance the AGP Upgrade Assistant in Android Studio to guide you +through the migration. + +If you find that the new DSL or Variant API are missing capabilities or +features, please file an [issue](https://issuetracker.google.com/issues/new?component=192708&template=840533) as soon as possible. + +## Built-in Kotlin + +Android Gradle plugin 9.0 introduces built-in Kotlin support and enables it +by default. That means you no longer have to apply the +`org.jetbrains.kotlin.android` (or `kotlin-android`) plugin in your build files +to compile Kotlin source files. +This simplifies the Kotlin integration with AGP, avoids the use of +deprecated APIs, and improves performance in some cases. + +Therefore, when you upgrade your project to AGP 9.0, you need to also +[migrate to built-in Kotlin](https://developer.android.com/build/migrate-to-built-in-kotlin) or [opt out](https://developer.android.com/build/migrate-to-built-in-kotlin#opt-out-of-built-in-kotlin). + +You can also [selectively disable built-in Kotlin support](https://developer.android.com/build/migrate-to-built-in-kotlin#selectively-disable) for Gradle +subprojects that don't have Kotlin sources. + +## Runtime dependency on Kotlin Gradle plugin + +To provide [built-in Kotlin](https://developer.android.com/build/migrate-to-built-in-kotlin) support, Android Gradle plugin 9.0 now has a +runtime dependency on Kotlin Gradle plugin (KGP) 2.2.10. +That means you no longer have to declare a KGP version, and if you use a KGP +version lower than 2.2.10, Gradle will automatically upgrade your KGP version to +2.2.10. +Likewise, if you use a KSP version lower than 2.2.10-2.0.2, AGP will upgrade it +to 2.2.10-2.0.2 to match the KGP version. + +### Upgrade to a higher KGP version + +To use a higher version of KGP or KSP, add the following to your top-level build +file: + + buildscript { + dependencies { + // For KGP + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:KGP_VERSION") + + // For KSP + classpath("com.google.devtools.ksp:symbol-processing-gradle-plugin:KSP_VERSION") + } + } + +### Downgrade to a lower KGP version + +You can only downgrade the KGP version if you've +[opted out of built-in Kotlin](https://developer.android.com/build/migrate-to-built-in-kotlin#opt-out-of-built-in-kotlin). +This is because AGP 9.0 enables built-in Kotlin by default, and built-in Kotlin +requires KGP 2.2.10 or higher. + +To use a lower version of KGP or KSP, declare that version in your top-level +build file using a [strict version](https://docs.gradle.org/current/userguide/dependency_versions.html#sec:strict-version) declaration: + + buildscript { + dependencies { + // For KGP + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin") { + version { strictly("KGP_VERSION") } + } + + // For KSP + classpath("com.google.devtools.ksp:symbol-processing-gradle-plugin") { + version { strictly("KSP_VERSION") } + } + } + } + +Note that the minimum KGP version you can downgrade to is 2.0.0. + +> [!CAUTION] +> **Caution:** When you downgrade the KGP version, your project might not be compatible with future minor releases of AGP 9, and it might not work with test fixtures. + +## IDE support for test fixtures + +AGP 9.0 brings full Android Studio IDE support for +[test fixtures](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/dsl/TestFixtures). + +## Fused Library Plugin + +The Fused Library Plugin (Preview) lets you publish multiple +libraries as a single Android Library AAR. This can make it easier for your +users to depend on your published artifacts. + +For information about getting started, see +[Publish multiple Android libraries as one with Fused Library](https://developer.android.com/build/publish-library/fused-library). + +## Behavior changes + +Android Gradle plugin 9.0 has the following new behaviors: + +| Behavior | Recommendation | +|---|---| +| Android Gradle plugin 9.0 uses NDK version `r28c` by default. | Consider specifying the NDK version you want to use explicitly. | +| Android Gradle plugin 9.0 by default requires consumers of a library to use the same or higher compile SDK version. | Use the same or higher compile SDK when consuming a library. If this is not possible, or you want to give consumers of a library you publish more time to switch, set [`AarMetadata.minCompileSdk`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/dsl/AarMetadata#minCompileSdk()) explicitly. | + +AGP 9.0 includes updates to the following Gradle properties' defaults. +This gives you the choice to preserve the AGP 8.13 behavior when upgrading: + +| Property | Function | Change from AGP 8.13 to AGP 9.0 | Recommendation | +|---|---|---|---| +| `android.newDsl` | Use the new DSL interfaces, without exposing the legacy implementations of the `android` block. This also means the legacy variant API, such as `android.applicationVariants` is no longer accessible. | `false` → `true` | You can opt out by setting `android.newDsl=false`. Once all plugins and build logic your project uses are compatible, remove the opt out. | +| `android.builtInKotlin` | Enables [built-in Kotlin](https://developer.android.com/build/releases/agp-9-0-0-release-notes#android-gradle-plugin-built-in-kotlin) | `false` → `true` | [Migrate to built-in Kotlin](https://developer.android.com/build/migrate-to-built-in-kotlin) if you can or [opt out](https://developer.android.com/build/migrate-to-built-in-kotlin#opt-out-of-built-in-kotlin). | +| `android.uniquePackageNames` | Enforces that each library has a distinct package name. | `false` → `true` | Specify unique package names for all libraries within your project. If that is not possible, you can disable this flag while you migrate. | +| `android.useAndroidx` | Use [`androidx`](https://developer.android.com/jetpack/androidx) dependencies by default. | `false` → `true` | Adopt [`androidx`](https://developer.android.com/jetpack/androidx/migrate) dependencies. | +| `android.default.androidx.test.runner` | Run on-device tests with the [`androidx.test.runner.AndroidJUnitRunner`](https://developer.android.com/training/testing/instrumented-tests/androidx-test-libraries/runner) class by default, replacing the default of the deprecated [`InstrumentationTestRunner`](https://developer.android.com/reference/android/test/InstrumentationTestRunner) for ``` android { defaultConfig { testInstrumentationRunner = "..." } } ``` | `false` → `true` | Adopt [`AndroidJUnitRunner`](https://developer.android.com/training/testing/instrumented-tests/androidx-test-libraries/runner), or specify your custom `testInstrumentationRunner` explicitly. | +| `android.dependency.useConstraints` | Controls the use of dependency constraints between configurations. The default in AGP 9.0 is `false` which only uses constraints in application device tests (AndroidTest). Setting this to `true` will revert back to the 8.13 behavior. | `true` → `false` | Don't use dependency constraints everywhere unless you need them. Accepting the new default of this flag also enables optimizations in the project import process which should reduce the import time for builds with many Android library subprojects. | +| `android.enableAppCompileTimeRClass` | Compile code in applications against a non-final R class, bringing application compilation in line with library compilation. This improves incrementality and paves the way for future performance optimizations to the resource processing flow. | `false` → `true` | Many projects can just adopt the new behavior with no source changes. If the R class fields are used anywhere that requires a constant, such as switch cases, refactor to use chained if statements. | +| `android.sdk.defaultTargetSdkToCompileSdkIfUnset` | Uses the compile SDK version as the default value for the target SDK version in apps and tests. Before this change, the target SDK version would default to the min SDK version. | `false` → `true` | Specify the target SDK version explicitly for apps and tests. | +| `android.onlyEnableUnitTestForTheTestedBuildType` | Only creates unit test components for the tested build type. In the default project this results in a single unit test for debug, where the previous behavor was to have unit tests run for debug or release. | `false` → `true` | If your project doesn't require tests to run for both debug and release, no change is required. | +| `android.proguard.failOnMissingFiles` | Fails the build with an error if any of the keep files specified in the AGP DSL don't exist on disk. Before this change typos in filenames would result in files being silently ignored. | `false` → `true` | Remove any invalid proguard files declarations | +| `android.r8.optimizedResourceShrinking` | Allows R8 to keep fewer Android resources by considering classes and Android resources together. | `false` → `true` | If your project's keep rules are already complete, no change is required. | +| `android.r8.strictFullModeForKeepRules` | Allows R8 to keep less by not implicitly keeping the default constructor when a class is kept. That is, `-keep class A` no longer implies `-keep class A { (); }` | `false` → `true` | If your project's keep rules are already complete, no change is required.
Replace `-keep class A` with `-keep class A { (); }` in your project's keep rules for any cases where you need the default constructor to be kept. | +| `android.defaults.buildfeatures.resvalues` | Enables [`resValues`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/variant/HasAndroidResources#resValues()) in all subprojects | `true` → `false` | Enable `resValues` in only the subprojects that need it by setting the following in those projects' Gradle build files: ``` android { buildFeatures { resValues = true } } ``` | +| `android.defaults.buildfeatures.shaders` | Enables [shader compilation](https://developer.android.com/ndk/guides/graphics/shader-compilers) in all subprojects | `true` → `false` | Enable shader compilation in only the subprojects that contain shaders to be compiled by setting the following in those projects' Gradle build files: ``` android { buildFeatures { shaders = true } } ``` | +| `android.r8.proguardAndroidTxt.disallowed` | In AGP 9.0, `getDefaultProguardFile()` will only support `proguard-android-optimize.txt` rather than `proguard-android.txt`. This is to prevent accidental usage of the `­dontoptimize` flag, which is included in `proguard-android.txt`. | `false` → `true` | You can explicitly specify `­dontoptimize` in a custom proguardFile if you want to avoid optimization, alongside using `proguard-android-optimize.txt`. Make sure to remove the `­dontoptimize` flag from this file if possible, as it reduces R8 optimization benefits. If not, opt out by setting `android.r8.globalOptionsInConsumerRules.disallowed=false`. | +| `android.r8.globalOptionsInConsumerRules.disallowed` | From AGP 9.0, Android library and feature module publishing will fail if consumer keep files contain problematic Proguard configurations. Consumer keep files that include global options like `­dontoptimize` or `­dontobfuscate` should only be used in application modules, and can reduce optimization benefits for library users. Android App module compilation will silently ignore any such global options if embedded in a pre-compiled dependency (JAR or AAR). You can see when this occurs by checking configuration.txt (typically in a path like `/build/outputs/mapping//configuration.txt`) for comments like: `# REMOVED CONSUMER RULE: ­dontoptimize` | `false` → `true` | Published libraries should remove any incompatible rules. Internal libraries should move any incompatible but required rules to a proguardFile in an app module instead. Opt out by setting `android.r8.globalOptionsInConsumerRules.disallowed=false`. Once all your consumer keep files are compatible, remove the opt out. | +| `android.sourceset.disallowProvider` | Disallow passing providers for generated sources using the [`AndroidSourceSet`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/dsl/AndroidSourceSet) DSL. | `false` → `true` | Use the [`Sources`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/variant/Sources) API on `androidComponents` to register generated sources. | +| `android.custom.shader.path.required` | Requires the shader compiler path to be explicitly set in `local.properties` if shader compilation is enabled. | `false` → `true` | Add `glslc.dir=/path/to/shader-tools` to your project's `local.properties`. | + +## Removed features + +Android Gradle plugin 9.0 removes the following functionality: + +- **Embedded Wear OS app support** + AGP 9.0 removes support for embedding Wear OS apps, which is no longer supported in Play. This includes removing the `wearApp` configurations and the [`AndroidSourceSet.wearAppConfigurationName`](https://developer.android.com/reference/tools/gradle-api/8.13/com/android/build/api/dsl/AndroidSourceSet#wearAppConfigurationName()) DSL. See [Distribute to Wear OS](https://developer.android.com/distribute/best-practices/launch/distribute-wear) for how to publish your app to Wear OS. +- **`androidDependencies` and `sourceSets` report task** +- **Density split APK support** + AGP 9.0 removes support for creating split APKs based on screen density. The functionality and the related APIs have been removed. To split APKs based on screen density using AGP 9.0 or higher, use [app bundles](https://developer.android.com/guide/app-bundle). + +## Changed DSL + +Android Gradle plugin 9.0 has the following breaking DSL changes: + +- The parameterization of [`CommonExtension`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/dsl/CommonExtension) has been removed. + + In itself, this is only a source-level breaking change to help + avoid future source-level breaking changes, but it also means that + the block methods need to move from [`CommonExtension`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/dsl/CommonExtension) to + [`ApplicationExtension`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/dsl/ApplicationExtension), [`LibraryExtension`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/dsl/LibraryExtension), + [`DynamicFeatureExtension`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/dsl/DynamicFeatureExtension) and [`TestExtension`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/dsl/TestExtension). + + When upgrading your project to AGP 9.0, refactor Gradle plugin code + which uses those parameters or the block methods. For example the following + plugin is updated to remove the type parameter and not rely on the + removed block methods: + + **AGP 8.13** + + val commonExtension: CommonExtension<*, *, *, *, *, *> = + extensions.getByType(CommonExtension::class) + commonExtension.apply { + defaultConfig { + minSdk { + version = release(28) + } + } + } + + **AGP 9.0** + + val commonExtension: CommonExtension = + extensions.getByType(CommonExtension::class) + commonExtension.apply { + defaultConfig.apply { + minSdk { + version = release(28) + } + } + } + + For plugins which target a range of AGP versions, using the getter directly + is binary compatible with AGP versions lower than 9.0. + +## Removed DSL + +Android Gradle plugin 9.0 removes: + +- [`AndroidSourceSet.jni`](https://developer.android.com/reference/tools/gradle-api/8.13/com/android/build/api/dsl/AndroidSourceSet#jni(kotlin.Function1)), because it was not functional. + +- [`AndroidSourceSet.wearAppConfigurationName`](https://developer.android.com/reference/tools/gradle-api/8.13/com/android/build/api/dsl/AndroidSourceSet#wearAppConfigurationName()), as it relates to the + removed embedded Wear OS app support. + +- [`BuildType.isRenderscriptDebuggable`](https://developer.android.com/reference/tools/gradle-api/8.13/com/android/build/api/dsl/BuildType#isRenderscriptDebuggable()), because it was not functional. + +- [`DependencyVariantSelection`](https://developer.android.com/reference/tools/gradle-api/8.13/com/android/build/api/dsl/DependencyVariantSelection). It is replaced By + [`DependencySelection`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/dsl/DependencySelection), which is exposed as + [`kotlin.android.localDependencySelection`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/dsl/KotlinMultiplatformAndroidLibraryExtension#localDependencySelection(kotlin.Function1)) + +- [`Installation.installOptions(String)`](https://developer.android.com/reference/tools/gradle-api/8.13/com/android/build/api/dsl/Installation#installOptions(kotlin.String)). It is replaced by the + mutable property of [`Installation.installOptions`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/dsl/Installation#installOptions()). + +- The experimental, but never stabilized [`PostProcessing`](https://developer.android.com/reference/tools/gradle-api/8.13/com/android/build/api/dsl/PostProcessing) block. + +- [`ProductFlavor.setDimension`](https://developer.android.com/reference/tools/gradle-api/8.13/com/android/build/api/dsl/ProductFlavor#setDimension(kotlin.String)), which is replaced by the + [`dimension`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/dsl/ProductFlavor#dimension()) property + +- `LanguageSplitOptions`, which was only useful for + [Google Play Instant](https://developer.android.com/topic/google-play-instant), which is deprecated. + +- [`DensitySplit`](https://developer.android.com/reference/tools/gradle-api/8.13/com/android/build/api/dsl/DensitySplit), because the feature is no longer supported. + Replacement is to use [App Bundles](https://developer.android.com/guide/app-bundle). + +## Removed APIs + +Android Gradle plugin 9.0 removes: + +- [`AndroidComponentsExtension.finalizeDSl`](https://developer.android.com/reference/tools/gradle-api/8.13/com/android/build/api/variant/AndroidComponentsExtension#finalizeDSl(org.gradle.api.Action)). It is replaced by + [`finalizeDsl`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/variant/DslLifecycle#finalizeDsl(kotlin.Function1)) + +- [`Component.transformClassesWith`](https://developer.android.com/reference/tools/gradle-api/8.13/com/android/build/api/variant/Component#transformClassesWith(java.lang.Class,com.android.build.api.instrumentation.InstrumentationScope,kotlin.Function1)). It is replaced by + [`Instrumentation.transformClassesWith`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/variant/Instrumentation#transformClassesWith(java.lang.Class,com.android.build.api.instrumentation.InstrumentationScope,kotlin.Function1)) + +- [`Component.setAsmFramesComputationMode`](https://developer.android.com/reference/tools/gradle-api/8.13/com/android/build/api/variant/Component#setAsmFramesComputationMode(com.android.build.api.instrumentation.FramesComputationMode)). It is replaced by + [`Instrumentation.setAsmFramesComputationMode`](https://developer.android.com/reference/tools/gradle-api/8.13/com/android/build/api/variant/Instrumentation#setAsmFramesComputationMode(com.android.build.api.instrumentation.FramesComputationMode)) + +- [`ComponentBuilder.enabled`](https://developer.android.com/reference/tools/gradle-api/8.13/com/android/build/api/variant/ComponentBuilder#enabled()). It is replaced by + [`ComponentBuilder.enable`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/variant/ComponentBuilder#enable()). + +- [`DependenciesInfoBuilder.includedInApk`](https://developer.android.com/reference/tools/gradle-api/8.13/com/android/build/api/variant/DependenciesInfoBuilder#includedInApk()). Is is replaced by + [`includeInApk`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/variant/DependenciesInfoBuilder#includeInApk()) + +- [`DependenciesInfoBuilder.includedInBundle`](https://developer.android.com/reference/tools/gradle-api/8.13/com/android/build/api/variant/DependenciesInfoBuilder#includedInBundle()). Is is replaced by + [`includeInBundle`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/variant/DependenciesInfoBuilder#includeInBundle()) + +- [`GeneratesApk.targetSdkVersion`](https://developer.android.com/reference/tools/gradle-api/8.13/com/android/build/api/variant/GeneratesApk#targetSdkVersion()). Is is replaced by [`targetSdk`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/variant/GeneratesApk#targetSdk()) + +- [`Variant.minSdkVersion`](https://developer.android.com/reference/tools/gradle-api/8.13/com/android/build/api/variant/Variant#minSdkVersion()). Is is replaced by [`minSdk`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/variant/Variant#minSdk()) + +- [`Variant.maxSdkVersion`](https://developer.android.com/reference/tools/gradle-api/8.13/com/android/build/api/variant/Variant#maxSdkVersion()). Is is replaced by [`maxSdk`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/variant/Variant#maxSdk()) + +- [`Variant.targetSdkVersion`](https://developer.android.com/reference/tools/gradle-api/8.13/com/android/build/api/variant/Variant#targetSdkVersion()). Is is replaced by [`targetSdk`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/variant/Variant#targetSdk()) + +- [`Variant.unitTest`](https://developer.android.com/reference/tools/gradle-api/8.13/com/android/build/api/variant/Variant#unitTest()), as it was not applicable to the + [`com.android.test`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/gradle/TestPlugin) plugin. + `unitTest` is available on [`VariantBuilder`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/variant/VariantBuilder) subtypes extending + [`HasUnitTest`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/variant/HasUnitTest). + +- [`VariantBuilder.targetSdk`](https://developer.android.com/reference/tools/gradle-api/8.13/com/android/build/api/variant/VariantBuilder#targetSdk()) and [`targetSdkPreview`](https://developer.android.com/reference/tools/gradle-api/8.13/com/android/build/api/variant/VariantBuilder#targetSdkPreview()), + as they were not meaningful in libraries. Use + [`GeneratesApkBuilder.targetSdk`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/variant/GeneratesApkBuilder#targetSdk()) or + [`GeneratesApkBuilder.targetSdkPreview`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/variant/GeneratesApkBuilder#targetSdkPreview()) + instead. + +- [`VariantBuilder.enableUnitTest`](https://developer.android.com/reference/tools/gradle-api/8.13/com/android/build/api/variant/VariantBuilder#enableUnitTest()), as it was not applicable to the + [`com.android.test`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/gradle/TestPlugin) plugin. + `enableUnitTest` is available on [`VariantBuilder`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/variant/VariantBuilder) subtypes extending + [`HasUnitTestBuilder`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/variant/HasUnitTestBuilder). + +- [`VariantBuilder.unitTestEnabled`](https://developer.android.com/reference/tools/gradle-api/8.13/com/android/build/api/variant/VariantBuilder#unitTestEnabled()) is removed in favor of the more + consistently + named [`enableUnitTest`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/variant/HasUnitTestBuilder#enableUnitTest()) on the [`VariantBuilder`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/variant/VariantBuilder) subtypes + extending [`HasUnitTestBuilder`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/variant/HasUnitTestBuilder). + +- [`VariantOutput.enable`](https://developer.android.com/reference/tools/gradle-api/8.13/com/android/build/api/variant/VariantOutput#enable()). Is is replaced by [`enabled`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/variant/VariantOutput#enabled()) + +- The deprecated and disabled `FeaturePlugin` and `FeatureExtension`. + +- The deprecated and disabled `BaseExtension.registerTransform` APIs, which + only remained to allow compiling against the latest AGP version while + targeting running on AGP 4.2 or lower. + +## Removed Gradle properties + +The following Gradle properties were initially added as ways to globally +disable features that were enabled by default. + +These features have been disabled by default since AGP 8.0 or lower. Enable +these features in only the sub-projects that use them for a more efficient +build. + +| Property | Function | Replacement | +|---|---|---| +| `android.defaults.buildfeatures.aidl` | Enables [AIDL compilation](https://source.android.com/docs/core/architecture/aidl) in all subprojects | Enable AIDL compilation in only the subprojects where there are AIDL sources by setting the following property in those projects' Gradle build files: ``` android { buildFeatures { aidl = true } } ``` in the Gradle build file of each subproject containing AIDL sources | +| `android.defaults.buildfeatures.renderscript` | Enables [RenderScript compilation](https://source.android.com/docs/core/architecture/vndk/renderscript) in all subprojects | Enable renderscript compilation in only the subprojects where there are renderscript sources by setting the following property in those projects' Gradle build files: ``` android { buildFeatures { renderScript = true } } ``` | + +## Enforced Gradle properties + +AGP 9.0 throws an error if you set the following Gradle properties. + +The [Android Gradle plugin Upgrade Assistant](https://developer.android.com/build/agp-upgrade-assistant) won't upgrade projects to +AGP 9.0 that use these properties. + +| Property | Function | +|---|---| +| `android.r8.integratedResourceShrinking` | Resource shrinking is now always run as part of R8, the previous implementation has been removed. | +| `android.enableNewResourceShrinker.preciseShrinking` | Resource shrinking now always uses precise resource shrinking, which enables more to be removed. | + +## R8 changes + +The following R8 changes are included in AGP 9.0.0. + +### New configuration option `-processkotlinnullchecks` + +We've added the new R8 option `-processkotlinnullchecks` to configure R8 for +processing Kotlin null checks. The option takes a mandatory argument that must +be one of the following three values: `keep`, `remove_message` and `remove`. +The option processes the following null checks added by the Kotlin +compiler: + + class kotlin.jvm.internal.Intrinsics { + void checkNotNull(java.lang.Object); + void checkNotNull(java.lang.Object, java.lang.String); + void checkExpressionValueIsNotNull( + java.lang.Object, java.lang.String); + void checkNotNullExpressionValue( + java.lang.Object, java.lang.String); + void checkReturnedValueIsNotNull( + java.lang.Object, java.lang.String); + void checkReturnedValueIsNotNull( + java.lang.Object, java.lang.String, java.lang.String); + void checkFieldIsNotNull(java.lang.Object, java.lang.String); + void checkFieldIsNotNull( + java.lang.Object, java.lang.String, java.lang.String); + void checkParameterIsNotNull(java.lang.Object, java.lang.String); + void checkNotNullParameter(java.lang.Object, java.lang.String); + } + +The option values, ordered from the weakest to the strongest, have the +following effect: + +- `keep` doesn't change the checks. +- `remove_message` rewrites each check method call to a call to `getClass()` on the first argument of the call (effectively keeping the null check, but without any message). +- `remove` completely removes the checks. + +By default R8 +uses `remove_message`. Any specification of `-processkotlinnullchecks` +will override that. If specified multiple times the strongest value is +used. + +### Stop propagating keep info to companion methods + +When keep rules match interface methods that are subject to desugaring, +R8 previously internally transferred the *disallow optimization* and *disallow +shrinking* bits to the synthesized companion methods. + +Starting with AGP 9.0, keep rules no longer apply to +companion methods. This is consistent with the fact that keep rules are +not applicable to other compiler synthesized fields/methods/classes. + +By transferring the *disallow optimization* and *disallow shrinking* bits to +the companion methods, the following use case was previously supported: + +1. Compile a library with `default`/`static`/`private` interface methods to DEX with `minSdk` \< 24 and rules that keep the interface methods. +2. Compile an app with the library on classpath and `-applymapping`. +3. Merge the app and the library. + +Note that this only works with `-applymapping` since the `disallow +obfuscation` bit is not transferred to the companion methods---that is, the +companion classes generated from step 1 would have obfuscated method +names. + +Going forward this use case is no longer supported for `minSdk` \< +24. The workaround is to do the following: + +1. Desugar the library with `default`/`static`/`private` interface methods to class files with `minSdk` \< 24. +2. Compile the desugared artifact using R8 and rules that keep the interface methods on the companion classes. +3. Compile the app with the library on classpath. +4. Merge the app and the desugared artifact. + +Another side effect of this is that it is no longer possible to keep the +inner class and enclosing method attributes for anonymous and local classes +inside interface companion methods. + +### Change the default emitted source file to `r8-map-id-` + +This change is in AGP starting from 8.12.0. + +The default emitted source file attribute for a class changes from +`SourceFile` to `r8-map-id-` when retracing is required (that is, +when either obfuscation or optimization is enabled). + +Given an obfuscated stack trace, the new source file attribute makes it +possible to extract the ID of the mapping file that is required for +retracing, which can be used to support +[automated retracing of stack traces in Logcat](https://developer.android.com/studio/preview/features#logcat-retrace). + +If a custom source file attribute is used (`-renamesourcefileattribute`) +this custom source file attribute continues to take precedence. + +In ProGuard compatibility mode (when `gradle.properties` contains +`android.enableR8.fullMode=false`), emitting a source file attribute of +`r8-map-id-` only takes effect if the `SourceFile` +attribute is *not* kept. Apps that use ProGuard compatibility mode and want +to include the mapping file ID in their stack traces should remove +`-keepattributes SourceFile` (or migrate to R8 full mode). + +The map ID used in `r8-map-id-` is the full map hash, and not a 7 +character prefix of the map hash which was previously used. + +### Enable use of minimized synthetic names in L8 desugaring + +The name of synthetic classes generated by D8 normally contains the substring +`$$ExternalSynthetic` that tells you that this is a synthetic generated by D8. +Moreover, the name of the synthetic also encodes the synthetic kind (for +example, `Backport`, `Lambda`). This has a negative impact on the resulting DEX +size, since the class names take up more space in the string pool. + +AGP 9.0 configures L8 (core library desugaring) so that the DEX +file containing all `j$` classes uses a new shortened class name format +for synthetic classes. The new class name uses a numeric ID (for example, `$1`). + +### Remove support for `-addconfigurationdebugging` + +AGP 9.0 removes support for `-addconfigurationdebugging`. The compiler now +reports a warning if the flag is used. + +### Remove support for generating L8 rules from D8/R8 + +This change is only relevant for developers using the D8/R8 command line or +APIs directly. + +R8 9.0 removes support for generating keep rules for L8 from D8 and R8. +You should instead use `TraceReferences` for this purpose. + +More specifically, the methods +`D8Command.builder.setDesugaredLibraryKeepRuleConsumer` and +`R8Command.Builder.setDesugaredLibraryKeepRuleConsumer` are removed, and the +support for `--desugared-lib-pg-conf-output` is removed from the command line +options of D8 and R8. + +## Fixed issues + +### Android Gradle plugin 9.0.0 + +| Fixed Issues || +|---|---| +| **Android Gradle Plugin** | |---| | [Issue #171293712](https://issuetracker.google.com/issues/171293712) Feature Request: Inject ideal AGP version as a property | | [Issue #443976533](https://issuetracker.google.com/issues/443976533) Stabilize SingleArtifact.VERSION_CONTROL_INFO_FILE | | [Issue #223643506](https://issuetracker.google.com/issues/223643506) androidTest connectedCheck logcat output is broken | | [Issue #386221070](https://issuetracker.google.com/issues/386221070) Built-in Kotlin support in AGP should not synchronize with the Kotlin sourcesets | | [Issue #460094802](https://issuetracker.google.com/issues/460094802) missingDimensionStrategy prefers a flavor maching its own name even from an unrelated dimension | | [Issue #386221070](https://issuetracker.google.com/issues/386221070) Built-in Kotlin support in AGP should not synchronize with the Kotlin sourcesets | | [Issue #471410336](https://issuetracker.google.com/issues/471410336) AGP 9.0.0-rc01 doesn't resolve Kotlin libraries via kotlin() function | | [Issue #450851465](https://issuetracker.google.com/issues/450851465) Built-in-kotlin does not publish kotlin-stdlib dependency constraint in maven pom | | [Issue #420592288](https://issuetracker.google.com/issues/420592288) Add a test case for divergence between compileSdk and targetSdk | | [Issue #449153004](https://issuetracker.google.com/issues/449153004) empty resConfigs value leads to obscure aapt error | | [Issue #447375921](https://issuetracker.google.com/issues/447375921) Pure Java project have dependency on kotlin stdlib. | | [Issue #368600704](https://issuetracker.google.com/issues/368600704) Remove deprecated KotlinMultiplatformAndroidCompilationBuilder properties in AGP 9.0 | | [Issue #445209309](https://issuetracker.google.com/issues/445209309) \`com.android.tools.build:gradle:9.0.0-alpha05\` should have an api dependency on KGP and gradle-api | | [Issue #452645779](https://issuetracker.google.com/issues/452645779) Rename com.android.experimental.built-in-kotlin Gradle plugin | | [Issue #446220448](https://issuetracker.google.com/issues/446220448) AGP9: \`variant.sources.kotlin!!.addGeneratedSourceDirectory()\` is not working | | [Issue #448450771](https://issuetracker.google.com/issues/448450771) Aar metadata checks on compile Sdk uses the old DSL | | [Issue #441523448](https://issuetracker.google.com/issues/441523448) Remove deprecated \`com.android.build.api.dsl.ManagedDevices.devices\` property | | [Issue #386221070](https://issuetracker.google.com/issues/386221070) Built-in Kotlin support in AGP should not synchronize with the Kotlin sourcesets | | [Issue #433758231](https://issuetracker.google.com/issues/433758231) Fail android library publishing if consumer keep file contains -dontobfuscate | | [Issue #241955408](https://issuetracker.google.com/issues/241955408) No Options to Print Mapping for Optimized Resources | | [Issue #436595826](https://issuetracker.google.com/issues/436595826) Make it an error to call finalizeDsl after this phase has been passed | | [Issue #457089670](https://issuetracker.google.com/issues/457089670) AGP initializes jetifier config even when jetifier is disabled | | [Issue #452246814](https://issuetracker.google.com/issues/452246814) built in kotlin does not add kotlinStdlib as a compile time dependency when \`kotlin.stdlib.default.dependency\` is true to module and pom files | | [Issue #442250902](https://issuetracker.google.com/issues/442250902) New optimizaation DSL does not create configuration.txt by default | | [Issue #443587266](https://issuetracker.google.com/issues/443587266) AGP 8.13.0 fails to verify nav graph in a module | | [Issue #444260628](https://issuetracker.google.com/issues/444260628) AGP uses deprecated Gradle API: multi-string notation | | [Issue #347732357](https://issuetracker.google.com/issues/347732357) Warn users trying to use legacy multidex library with minSdkVersion \>=21 | | [Issue #333831734](https://issuetracker.google.com/issues/333831734) A build fails if there are code generation tasks | | [Issue #446123111](https://issuetracker.google.com/issues/446123111) With \`android.builtInKotlin=false\` and \`android.newDsl=false\` and \`android.enableLegacyVariantApi=false\`, using \`kotlin-android\` plugin will fail with "API 'applicationVariants' is obsolete" | | [Issue #443037365](https://issuetracker.google.com/issues/443037365) Built-in Kotlin fails to resolve unversioned kotlin-stdlib when kotlin.stdlib.default.dependency=false | | [Issue #445967244](https://issuetracker.google.com/issues/445967244) DexData opens a file without closing, preventing cleanup | | [Issue #368609737](https://issuetracker.google.com/issues/368609737) AndroidSourceDirectorySet should stop extending PatternFilterable in AGP 9.0 | | [Issue #389707041](https://issuetracker.google.com/issues/389707041) Test Fixture Error in test only modules | | [Issue #353249347](https://issuetracker.google.com/issues/353249347) Incorrect error when using context receivers in test fixtures | | [Issue #351046197](https://issuetracker.google.com/issues/351046197) Incorrect IDE errors for Kotlin code in testFixtures | | [Issue #446889652](https://issuetracker.google.com/issues/446889652) \`legacy-kapt\` plugin skips annotation processing unlike \`kotlin-kapt\` | | [Issue #446492061](https://issuetracker.google.com/issues/446492061) compileSdkSpec.minorApiLevel is not working with SettingsExtension | | [Issue #429253579](https://issuetracker.google.com/issues/429253579) \[fused lib - public\] Generated fused library does not include sources | | [Issue #149770867](https://issuetracker.google.com/issues/149770867) extractNativeLibs and useEmbeddedDex should not be coming from the manifest | | [Issue #449114518](https://issuetracker.google.com/issues/449114518) Warnings from R8 in AGP 9.0.0-alpha09 | | [Issue #368426598](https://issuetracker.google.com/issues/368426598) Remove deprecated AndroidSourceSet.jni in AGP 9.0 | | [Issue #368484483](https://issuetracker.google.com/issues/368484483) Remove Installation.installOptions() in AGP 9.0 | | [Issue #368482484](https://issuetracker.google.com/issues/368482484) Remove BuildType.isRenderscriptDebuggable in AGP 9.0. | | [Issue #428646179](https://issuetracker.google.com/issues/428646179) Remove android.defaults.buildfeatures.renderscript | | [Issue #436887358](https://issuetracker.google.com/issues/436887358) \`com.android.kotlin.multiplatform.library\` crashes with Gradle Managed Devices | | [Issue #428645763](https://issuetracker.google.com/issues/428645763) Remove \`android.defaults.buildfeatures.aidl\` defaults gradle.properties flags | | [Issue #294183018](https://issuetracker.google.com/issues/294183018) Fail build when proguard file does not exist | | [Issue #254305041](https://issuetracker.google.com/issues/254305041) remove buildconfig defaults gradle.properties flags | | [Issue #280674230](https://issuetracker.google.com/issues/280674230) Change the app's targetSdk default value to be based on compileSdk instead of minSdk | | [Issue #436878535](https://issuetracker.google.com/issues/436878535) When \`isIncludeAndroidResources\` is enabled, \`process{Variant}UnitTestManifest\` fails to merge tools:overrideLibrary usages in AGP 8.12.0 | | [Issue #411739086](https://issuetracker.google.com/issues/411739086) AGP causing deprecation warnings in Gradle for JVM test tasks | | [Issue #235457021](https://issuetracker.google.com/issues/235457021) DependencyReportTask is incompatible with the configuration cache | | [Issue #369246556](https://issuetracker.google.com/issues/369246556) Switch default source/target Java version from Java 8 to Java 11 in AGP 9.0 | | [Issue #258855275](https://issuetracker.google.com/issues/258855275) Flip android.useAndroidX default to true | | [Issue #442763200](https://issuetracker.google.com/issues/442763200) Better exception when applying kapt plugin with built-in Kotlin. | | [Issue #441679226](https://issuetracker.google.com/issues/441679226) android.proguard.failOnMissingFiles is not working for consumerProguardFiles | | [Issue #443051391](https://issuetracker.google.com/issues/443051391) Update Kotlin Gradle plugin dependency to 2.2.10 | | [Issue #429981132](https://issuetracker.google.com/issues/429981132) Create KotlinJvmAndroidCompilation using KGP API | | [Issue #442869731](https://issuetracker.google.com/issues/442869731) Kotlin explicit API mode applied to test sources | | +| **Lint** | |---| | [Issue #430991549](https://issuetracker.google.com/issues/430991549) AGP 8.11.0: lintAnalyzeRelease task crashes when applying .gradle.kts files with apply(from = "...") | | [Issue #441536820](https://issuetracker.google.com/issues/441536820) Lint ChecksSdkIntAtLeast Check does not check if the annotated value is correct | | [Issue #446696613](https://issuetracker.google.com/issues/446696613) Built-in Kotlin does not add .kotlin_module to META-INF | | [Issue #449031505](https://issuetracker.google.com/issues/449031505) Lint classpath contains duplicate classes at different versions | | [Issue #448148350](https://issuetracker.google.com/issues/448148350) Overriding private resources workaround not working (tools:override = "true") | | [Issue #405676712](https://issuetracker.google.com/issues/405676712) Bug: removal of unused resources doesn't also remove the translations of them, and doesn't ask about it either | | [Issue #440415636](https://issuetracker.google.com/issues/440415636) Lint throwing warning "Could not clean up K2 caches" | | [Issue #440415636](https://issuetracker.google.com/issues/440415636) Lint throwing warning "Could not clean up K2 caches" | | +| **Lint Integration** | |---| | [Issue #460068798](https://issuetracker.google.com/issues/460068798) AndroidLintAnalysisTask cache misses across different JDK vendors or minor versions due to systemPropertyInputs.javaVersion differences | | [Issue #444447002](https://issuetracker.google.com/issues/444447002) Lint automatically uses latest installed SDK despite compileSdk, doesn't register as task input and breaks caching | | +| **Shrinker (R8)** | |---| | [Issue #454927488](https://issuetracker.google.com/issues/454927488) R8 optimized resource shrinking silently fails if using final resource IDs | | + +
\ No newline at end of file diff --git a/.agents/skills/build/agp/agp-9-upgrade/references/buildconfig.md b/.agents/skills/build/agp/agp-9-upgrade/references/buildconfig.md new file mode 100644 index 00000000..273f876c --- /dev/null +++ b/.agents/skills/build/agp/agp-9-upgrade/references/buildconfig.md @@ -0,0 +1,54 @@ +When an Android module contains custom BuildConfig fields, the following steps +are necessary to ensure a correct build. + +### Step 1: Enable the buildConfig build feature + +In a build script: + + android { + buildFeatures { + buildConfig = true + } + } + +In custom build-logic for an app module: + + extensions.configure { + buildFeatures { + buildConfig = true + } + } + +In custom build-logic for a library module: + + extensions.configure { + buildFeatures { + buildConfig = true + } + } + +In custom build-logic using `CommonExtension`: + + extensions.configure { + buildFeatures { + buildConfig = true + } + } + +### Step 2: Migrate to the new API + +Use the **addCustomBuildConfigFields** recipe from the [gradle-recipes](https://developer.android.com/agents/skills/build/agp/agp-9-upgrade/references/recipes) +repository. + +**IMPORTANT:** For `BuildConfigField`s with a type of `String`, the `value` field +*must* include quotation marks as part of the String. For example: + + BuildConfigField( + type = "String", + value = "\"Some value\"", + comment = "Optional comment", + ) + +It is an **error** if the `value` field doesn't include quotation marks as +part of the String. For example, `value = "Some value"` **is an error** . This is +because the `value` is written out literally. \ No newline at end of file diff --git a/.agents/skills/build/agp/agp-9-upgrade/references/ksp-kapt.md b/.agents/skills/build/agp/agp-9-upgrade/references/ksp-kapt.md new file mode 100644 index 00000000..e8a5d33c --- /dev/null +++ b/.agents/skills/build/agp/agp-9-upgrade/references/ksp-kapt.md @@ -0,0 +1,39 @@ +When migrating to built-in Kotlin, it is important to consider usage of `kapt` +and the `org.jetbrains.kotlin.kapt` (also known as the `kotlin("kapt")`) plugin. +The goal is to migrate as many `kapt` usages to `ksp` as possible. + +Follow these steps when migrating `kapt`: + +## 1. Remove all references to the `org.jetbrains.kotlin.kapt` plugin + +The `org.jetbrains.kotlin.kapt` (also known as `kotlin("kapt")`) plugin is +incompatible with built-in Kotlin. Remove it when migrating to built-in Kotlin. + +## 2. Check each usage of `kapt` + +Check each usage of `kapt` to see if it is compatible with `ksp`. To check if a +dependency is compatible with `ksp`, inspect the dependency's jar. For it to be +compatible with `ksp`, the jar must have a file, +`services/com.google.devtools.ksp.processing.SymbolProcessorProvider`. If it +does not, it is **incompatible** with `ksp`. + +For example, the `androidx.room:room-compiler` library is compatible with KSP +since version 2.3.0-beta02. We can verify this by finding the jar file in the +Gradle caches directory, which is typically located at +`~/.gradle/caches/modules-2/files-2.1/` on Linux and Mac. In this specific case, +the `androidx.room:room-compiler` dependency is located at +`~/.gradle/caches/modules-2/files-2.1/androidx.room/room-compiler/`. + +More generally, you can find a dependency by looking in +`~/.gradle/caches/modules-2/files-2.1/group-name/artifact-name/`. + +## 3. Migrate to KSP where possible + +For each usage of `kapt` that is compatible with `ksp`, use `ksp`. The prior +step explains how to check compatibility. + +## 4. Apply legacy-kapt + +If a Gradle module has `kapt` dependencies that cannot be migrated to `ksp` +because they are incompatible (see step 2), then leave that dependency alone and +apply the `com.android.legacy-kapt` plugin. \ No newline at end of file diff --git a/.agents/skills/build/agp/agp-9-upgrade/references/paparazzi-gradle-9.md b/.agents/skills/build/agp/agp-9-upgrade/references/paparazzi-gradle-9.md new file mode 100644 index 00000000..a20da2a6 --- /dev/null +++ b/.agents/skills/build/agp/agp-9-upgrade/references/paparazzi-gradle-9.md @@ -0,0 +1,30 @@ +If Paparazzi is used in the project, update it to version 2.0.0-alpha04 or +higher. + +Paparazzi version 2.0.0-alpha04 and lower is not fully compatible with Gradle +9, and Gradle 9 is required by AGP 9. This means that, without workarounds, +projects that use Paparazzi v2.0.0-alpha04 and lower cannot migrate to AGP 9. + +At time of writing, there are no higher versions of Paparazzi. That is, +v2.0.0-alpha04 is the latest release. + +The issue is due to Paparazzi using internal classes from Gradle that tend to +move in breaking ways without warning. This specific issue is related to HTML +test reports. To work around it, disable those HTML test reports. Here +are two examples of how to do this, one for Kotlin DSL and the other for Groovy +DSL. Any module that has the paparazzi plugin (`app.cash.paparazzi`) applied +must apply one of these two workarounds. + +Kotlin DSL: + + tasks.withType().configureEach { + // https://github.com/cashapp/paparazzi/issues/2111 + reports.html.required = false + } + +Groovy DSL: + + tasks.withType(Test).configureEach { + // https://github.com/cashapp/paparazzi/issues/2111 + reports.html.required = false + } \ No newline at end of file diff --git a/.agents/skills/build/agp/agp-9-upgrade/references/recipes.md b/.agents/skills/build/agp/agp-9-upgrade/references/recipes.md new file mode 100644 index 00000000..c26d85fd --- /dev/null +++ b/.agents/skills/build/agp/agp-9-upgrade/references/recipes.md @@ -0,0 +1,63 @@ +When migrating to AGP's new DSL, any Gradle code (plugins or logic in build +scripts) that relied on the old DSL will stop working. Such code must be +migrated. + +## Guidelines + +- **DO NOT** search the web for examples of how to do this. Use the + **gradle-recipes** repository examples **only**. +- **DO NOT** use AGP internals in migrated code. +- **DO** use only public APIs in migrated code. + +In some cases, there is a one-to-one replacement for the old code. Some examples +are in [the AGP 9.0.0 release notes](https://developer.android.com/build/releases/agp-9-0-0-release-notes). + +In other cases, there is no direct one-to-one replacement. For these situations, +the [gradle-recipes repo](https://github.com/android/gradle-recipes) is a great resource. You can checkout one of its +AGP 9.x branches, such as `agp-9.0`, `agp-9.1`, or `agp-9.2`. These branches +contain recipes for common situations in Android projects. The following table +lists the compatibility for recipes for each version of AGP. + +## Compatibility table + +| AGP version | gradle-recipes branch | +|---|---| +| 9.0.x | agp-9.0 | +| 9.1.x | agp-9.1 | +| 9.2.x | agp-9.2 | + +## Recipes and use-cases + +The following table links use-cases to recipes. + +| Recipe | Use-case | +|---|---| +| addCustomBuildConfigFields | Add custom BuildConfig fields | +| listenToArtifacts | Rename APK | + +Additional details for each use-case follow. + +### Add custom BuildConfig fields + +See the detailed guide at [BuildConfig](https://developer.android.com/agents/skills/build/agp/agp-9-upgrade/references/buildconfig). + +### Renaming an APK + +In the old DSL, an APK could be renamed very simply. Here's an example: + + android { + applicationVariants.all { + outputs.all { + val output = this as com.android.build.gradle.api.ApkVariantOutput + val fileName = output.outputFileName + if (fileName.contains("release")) { + output.outputFileName = "my-cool-new-name.apk" + } + } + } + } + +However, with AGP 9 and the new DSL, `applicationVariants` is no longer +available. You must instead react to artifact creation using the +`androidComponents.onVariants` API. A complete example of this is available in +the **gradle-recipes** repository in the `listenToArtifacts` recipe. \ No newline at end of file diff --git a/.agents/skills/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/SKILL.md b/.agents/skills/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/SKILL.md new file mode 100644 index 00000000..ed21187d --- /dev/null +++ b/.agents/skills/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/SKILL.md @@ -0,0 +1,123 @@ +--- +name: migrate-xml-views-to-jetpack-compose +description: Provides a structured workflow for migrating an Android XML View to Jetpack + Compose. This skill details the step-by-step process, from planning and dependency + setup, to theming and layout migration, validation and XML cleanup. Use this skill + when you need to migrate an XML View to Jetpack Compose in an Android project. It + solves the problem of converting the UI of a legacy XML View into modern, declarative + Compose components while maintaining interoperability. +license: Complete terms in LICENSE.txt +metadata: + author: Google LLC + keywords: + - Jetpack Compose + - migration + - XML + - Views + - interoperability + - incremental adoption + - UI development +--- + +This skill guides through the process of migrating an existing Android XML View +to Jetpack Compose. It performs a stable, safe and visually consistent +transition by following a structured, 10-step methodology. This skill migrates +UI (XML to Jetpack Compose) only. + +## Objective + +To systematically convert a single legacy XML layout into modern, declarative +Jetpack Compose UI while maintaining pixel-perfect visual parity and functional +integrity. + +## Summary of the 10-step migration process + +1. **Identify the optimal XML candidate for migration** +2. **Analyze the project and layout** +3. **Create a plan** +4. **Capture the XML View UI** +5. **Set up Compose dependencies and compiler** +6. **Set up Compose theming** +7. **Migrate the XML layout to Compose** +8. **Validate the migration** +9. **Replace usages** +10. **XML code removal** + +## Detailed steps + +### Step 1: Identify the optimal XML candidate for migration + +If the user has explicitly specified a target XML layout, proceed to Step 2. +Otherwise, analyze the codebase to identify the best candidate for migration by +following the logic in [references/identify-optimal-xml-candidate.md](references/identify-optimal-xml-candidate.md). + +### Step 2: Analyze the project and layout + +Analyze the identified XML View's structure, hierarchy, and implementation +details. +Use [references/analysis-of-the-project-and-layout.md](references/analysis-of-the-project-and-layout.md) to +guide your technical audit of the layout and surrounding project context. + +### Step 3: Create a plan + +Using the outputs and analysis done in the Step 1 and 2, generate a +step-by-step plan for the migration. If you support user interaction, present +to the user and ask for approval before proceeding. If user interaction is not +supported, proceed to Step 4 following the generated plan. + +### Step 4: Capture the XML View UI + +**IF** you support user interaction, ask the user to upload a screenshot of the +XML View UI or provide an absolute path to a file. Use this image as a visual +reference for the layout migration in Step 7. +**ELSE IF** you are able to run an Android emulator, locate an existing +screenshot test for the XML candidate. If none exists, create one using the +existing project testing framework. If no framework exists, +use **UI Automator** or **Espresso** to create a screenshot test with minimum +required setup. Run the test and take a baseline screenshot of the XML UI. +**ELSE** proceed to Step 5. + +### Step 5: Set up Compose dependencies and compiler + +Check `build.gradle` or `libs.versions.toml` for Compose dependencies and +compiler setup. If missing, use +[Setup Compose Dependencies and Compiler](references/android/develop/ui/compose/setup-compose-dependencies-and-compiler.md). +Run a sync to ensure dependencies resolve without errors. + +### Step 6: Set up Compose theming + +If the project already has Compose theming set up, proceed to Step 7. If Compose +theming is missing, initialize it. For Material-based projects, follow +[Material 3 migration guidelines](references/android/develop/ui/compose/designsystems/migrate-xml-theme-to-compose.md). +For custom design systems, apply expert judgment to migrate XML theming and +match existing styles. +**Constraints:** Do not migrate the entire theme. Implement only the minimum +theming required for the specific XML candidate. Maintain original XML themes +for interoperability. Maintain existing project code conventions, patterns, +names and values. + +### Step 7: Migrate the XML View to Compose + +Convert the XML candidate to Jetpack Compose code, referencing +[references/xml-layout-migration.md](references/xml-layout-migration.md) and the image from Step 4. +You must include a **Compose Preview** for the newly created composable to +facilitate visual verification. + +### Step 8: Replace usages + +Replace the usages of the migrated XML layout to use the new Compose component. + +- To add Compose in Views, use [Compose in Views](references/android/develop/ui/compose/migrate/interoperability-apis/compose-in-views.md). +- To add Views in Compose, use [Views in Compose](references/android/develop/ui/compose/migrate/interoperability-apis/views-in-compose.md). + +### Step 9: Validate the migration + +Compare the baseline screenshot image from Step 4 with the rendered Compose +Preview of the new composable. Ignore string content; focus on layout and +styling. Iterate on the Compose code until visual parity is achieved. Once +verified, write a Compose UI test for the new composable. + +### Step 10: XML code removal + +Delete the migrated XML file and its associated legacy tests. **Caution:** Only +remove code and resources that are not referenced by other parts of the project. \ No newline at end of file diff --git a/.agents/skills/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/analysis-of-the-project-and-layout.md b/.agents/skills/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/analysis-of-the-project-and-layout.md new file mode 100644 index 00000000..3e2f0c34 --- /dev/null +++ b/.agents/skills/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/analysis-of-the-project-and-layout.md @@ -0,0 +1,42 @@ +## 1. Project health \& build validation + +Before performing any analysis, you must confirm the project is in a functional state. +\* **Integrity check:** Verify the project syncs (Gradle) and builds successfully. +\* **Error resolution:** If there are pre-existing build errors or sync failures, you must report these immediately and attempt to fix. **Do not proceed** with migration until a stable baseline is established. + +## 2. Compose pattern \& consistency analysis + +If Jetpack Compose is already present, you must align with the established implementation style. +\* **Pattern identification:** Scan the codebase for `@Composable` functions. Identify the project's "Best Practices" regarding state hoisting, composable construction and naming conventions, and file organization. +\* **Theming review:** Determine how `MaterialTheme` or custom theme systems are implemented. +\* Identify if the project uses a custom design system theme. +\* Map how attributes, styles, and other theme components are accessed in Compose. + +## 3. Design system \& infrastructure audit + +Understand the design system classification (e.g. Material 2, Material 3, or custom design system). +\* **Resource mapping:** Locate central XML definitions: +\* `colors.xml` (Light/Dark variants) +\* `dimens.xml` +\* `styles.xml` / `themes.xml` +\* **Hybrid analysis:** Determine if the project is **XML-only** , **Compose-only** , or **Hybrid** . +\* **Reuse constraint:** If a Compose theming layer (e.g., `AppTheme.kt`) already exists, **DO NOT** generate a new one. You must reuse the existing infrastructure and contribute to it by following its existing implementation pattern. + +## 4. Candidate layout decomposition + +Analyze the specific XML layout targeted for migration. You must extract and document the following requirements for the new composable: +\* **Inputs:** UI State objects, primitive parameters, and click listeners. +\* **Styling:** Specific color constants, typography styles, and shape definitions referenced in the XML. +\* **Resources:** Identifying string resources, drawables, and dimensions. +\* **Layout logic:** Modifiers required to replicate the XML constraints (padding, alignment, weight). + +## 5. Architectural \& non-UI analysis + +Understand the environment in which the UI resides to ensure proper integration. +\* **State management:** Identify the usage of `ViewModel`, `Flow`, or `LiveData`. +\* **Dependency Injection:** Check for Hilt, Koin, or manual DI to understand how dependencies are provided to the UI layer. +\* **Testing \& architecture:** Note the architectural pattern (MVI, MVVM, or custom architecture setup.) and existing UI testing frameworks to ensure the migrated code remains testable. Unless the user explicitly requests, **DO NOT** make any changes to any non-UI code that aren't strictly required for the migration of the XML View. + +*** ** * ** *** + +> **Pro-tip:** Always prioritize the "Existing infrastructure" over "Default templates." If the project has a custom way of handling spacing or colors, composable code, or any other project layer, your generated Compose code must reflect that specific implementation. \ No newline at end of file diff --git a/.agents/skills/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/designsystems/migrate-xml-theme-to-compose.md b/.agents/skills/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/designsystems/migrate-xml-theme-to-compose.md new file mode 100644 index 00000000..d8230f89 --- /dev/null +++ b/.agents/skills/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/designsystems/migrate-xml-theme-to-compose.md @@ -0,0 +1,168 @@ +When you introduce Compose in an existing app, you need to migrate your Material +XML themes to use `MaterialTheme` for Compose components. This means your app's +theming will have two sources of truth: the View-based theme and the Compose +theme. Any changes to your styling need to be made in multiple places. Once +your app is fully migrated to Compose, remove your XML theming. + +You can use the [Material Theme Builder](https://m3.material.io/theme-builder) +tool for migrating colors. + +When you start the migration from XML to Compose, migrate the theming to +Material 3 Compose theming. + +## Glossary + +| Term | Definition | +|---|---| +| `MaterialTheme` | The composable function that provides theming (colors, typography, shapes) to Compose UI components. | +| `Shape` | A Compose object used to define custom component shapes for a `MaterialTheme`. | +| `Typography` | A Compose object used to define custom text styles (font families, sizes, weights) for a `MaterialTheme`. | +| `Color` | A Compose object used to define custom color schemes for `MaterialTheme`. | +| XML Theme | The Android theming system defined in XML files, used by the View system. | + +## Limitations + +Before migrating, be aware of the following limitations: + +- This guide focuses on migrating to Material 3 only. For migrating from alternative design systems, see [Material 2](https://developer.android.com/develop/ui/compose/designsystems/material) or [Custom design systems in Compose](https://developer.android.com/develop/ui/compose/designsystems/custom). +- The ultimate goal is a complete migration to Compose, which allows for the removal of XML theming. This guide explains how to migrate, but it doesn't explain how to finally remove XML theming. + +## Step 1: Evaluate the design system + +Identify which design system is used in the XML View project. +Analyze the migration path and necessary steps to migrate the existing design +system to Material 3 in Compose. + +## Step 2: Identify theme source files + +In XML you write `?attr/colorPrimary`. In Compose, you access theme values +with `MaterialTheme.*`: + +Identify and locate all XML resources and files necessary for theming: +light and dark color schemes and qualifiers, themes, shapes, dimensions, +typography, styles and other relevant files. + +Resources such as strings can be reused as is and don't need to be migrated. + +## Step 3: Migrate colors + +**Key principle:** XML uses named hex colors. +Material 3 uses *semantic roles* (e.g., `primary`, `onPrimary`, `surface`). +Stop naming colors by their hex; name them by their role. + +Examples: + +| XML color name | Material 3 role | +|---|---| +| `colorPrimary` | `primary` | +| `colorPrimaryDark` / `colorPrimaryVariant` | `primaryContainer` or `secondary` | +| `colorAccent` | `secondary` or `tertiary` | +| `colorOnPrimary` | `onPrimary` | +| `android:colorBackground` | `background` | +| `colorSurface` | `surface` | +| `colorOnSurface` | `onSurface` | +| `colorError` | `error` | +| `colorOnError` | `onError` | +| `colorOutline` | `outline` | +| `colorSurfaceVariant` | `surfaceVariant` | +| `colorOnSurfaceVariant` | `onSurfaceVariant` | + +*** ** * ** *** + +Migrate the dark and light color schemes from XML to their equivalents in +Material 3 Compose. + +> [!NOTE] +> **Note:** Material 3 naming differs from Material 2 color naming. + +## Step 4: Migrate custom shapes and typography + +- If your app uses custom shapes: + + 1. In your Compose code, define a `Shape` object to replicate your XML shape definitions. + 2. Provide this `Shape` object to your `MaterialTheme`. + + For more details, see [shapes](https://developer.android.com/develop/ui/compose/designsystems/material3#shapes). +- If your app uses custom typography: + + 1. In your Compose code, define a `Typography` object in your Compose code to replicate your XML text styles and font definitions. + 2. Provide this `Typography` object to your `MaterialTheme`. + + For more details, see [typography](https://developer.android.com/develop/ui/compose/designsystems/material3#typography). + +| Compose role | XML name | +|---|---| +| `displayLarge` | `TextAppearance.Material3.DisplayLarge` | +| `displayMedium` | `TextAppearance.Material3.DisplayMedium` | +| `displaySmall` | `TextAppearance.Material3.DisplaySmall` | +| `headlineLarge` | `TextAppearance.Material3.HeadlineLarge` | +| `headlineMedium` | `TextAppearance.Material3.HeadlineMedium` | +| `headlineSmall` | `TextAppearance.Material3.HeadlineSmall` | +| `titleLarge` | `TextAppearance.Material3.TitleLarge` | +| `titleMedium` | `TextAppearance.Material3.TitleMedium` | +| `titleSmall` | `TextAppearance.Material3.TitleSmall` | +| `bodyLarge` | `TextAppearance.Material3.BodyLarge` | +| `bodyMedium` | `TextAppearance.Material3.BodyMedium` | +| `bodySmall` | `TextAppearance.Material3.BodySmall` | +| `labelLarge` | `TextAppearance.Material3.LabelLarge` | +| `labelMedium` | `TextAppearance.Material3.LabelMedium` | +| `labelSmall` | `TextAppearance.Material3.LabelSmall` | + +## Step 5: Migrate styles (styles.xml) + +XML styles (styles.xml) system defines styles and appearance of: +1. Widgets, components, themes for windows and dialogs +2. Typography +3. Themes and overlays +4. Shapes + +XML Views and components combine multiple attributes to create a style. +They set their styles from styles.xml in two different ways: +1. Setting "style="@style/..." directly and explicitly in the XML View +2. Setting the style indirectly and implicitly for a component as part of a larger Theme (theme.xml) + +Styles have no direct equivalent in Compose - instead styles are passed as: +parameters to composables, defined in the AppTheme, or by creating layered, +reusable composable variations with the defined style. + +Provide separate @Composable functions named according to the style and the +base component, to signify the difference in styling and use cases for those +components. + +- **Pattern:** If an XML element uses a custom style (e.g., `style="@style/MyPrimaryButton"`), don't try to replicate the style inline. Instead, suggest creating a specific composable. +- **Example:** + - *XML:* ` + +
+ +If you run into problems [file an issue here](https://issuetracker.google.com/issues/new?component=1750212&template=2102223&title=%5BMigration%5D). + +## Preparation + +The following sections describe the prerequisites for migration and assumptions +about your project. They also cover the features that are supported for +migration, and those that aren't. + +### Prerequisites + +- You must use a `compileSdk` of 36 or later. +- You should be familiar with [navigation terminology](https://developer.android.com/guide/navigation). +- Destinations are composable functions. Navigation 3 is designed exclusively for Compose. To use Fragments and Views in Compose, see [Using Views in + Compose](https://developer.android.com/develop/ui/compose/migrate/interoperability-apis/views-in-compose). +- Routes are strongly typed. If you use string-based routes, [migrate to + type-safe routes](https://medium.com/androiddevelopers/type-safe-navigation-for-compose-105325a97657) first ([example](https://github.com/android/nowinandroid/pull/1413)). +- *Optional (but highly recommended)*: Test coverage that verifies existing navigation behavior. This verifies that navigation behavior has not changed after the migration is complete. + +### Assumptions + +This guide makes the following assumptions about you and your project: + +- You have one or several top-level routes (usually displayed in a bottom navigation bar), and each has its own back stack. +- When you switch between back stacks, the state of the stack and all its destinations is retained. +- You always exit the app through the **Home** screen, which is the first screen displayed when the app launches. +- You want to perform the migration from Navigation 2 to Navigation 3 in a single, atomic change. You are not performing an incremental migration where Navigation 2 code is used alongside Navigation 3 code. + +**AI Agent**: Before changing any code, check if these assumptions about the +user's project are true. If not, stop the migration and ask the user how to +proceed. + +### Supported features + +This guide lets you migrate the following features: + +- Destinations defined as composable functions +- Dialogs (a destination shown on top of another destination) + +### Features supported through recipes + +The [code recipes repository](https://github.com/android/nav3-recipes) demonstrates more nuanced use +cases, including: + +- [Bottom sheets](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/bottomsheet) (instructions are provided in this guide) +- [Modularized navigation code and injected destinations](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/modular/hilt) +- [Using and passing arguments to `ViewModel`](https://github.com/android/nav3-recipes?tab=readme-ov-file#passing-navigation-arguments-to-viewmodels) +- [Returning results from a screen](https://github.com/android/nav3-recipes?tab=readme-ov-file#returning-results) + +If your project has any of these features, check the relevant recipe to +understand how to implement them. + +**AI Agent**: Before changing any code, check if the project contains any +features supported through recipes. If it does, check the recipe's README and +source code. Create a migration plan based on the recipe. Do not proceed without +confirming the plan with the user. + +### Unsupported features + +This migration guide and the code recipes don't yet support the following +features. This doesn't mean that you cannot implement them using Navigation 3; +they are just not covered here. + +- More than one level of nested navigation +- Shared destinations: screens that can move between different back stacks +- [Custom destination types](https://developer.android.com/guide/navigation/design/kotlin-dsl#custom) +- Deep links + +**AI Agent**: Before changing any code, check if the project contains any of the +unsupported features. If it does, do not proceed. Inform the user of the +unsupported feature and ask for further instructions. + +## Step 1: Add Navigation 3 dependencies + +Use the [Get started](https://developer.android.com/guide/navigation/navigation-3/get-started) page to add the Navigation 3 dependencies to your +project. The core dependencies are provided for you to copy. + +**lib.versions.toml** + + [versions] + nav3Core = "1.0.0" + + # If your screens depend on ViewModels, add the Nav3 Lifecycle ViewModel add-on library + lifecycleViewmodelNav3 = "2.10.0-rc01" + + [libraries] + # Core Navigation 3 libraries + androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" } + androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" } + + # Add-on libraries (only add if you need them) + androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" } + +**app/build.gradle.kts** + + dependencies { + implementation(libs.androidx.navigation3.ui) + implementation(libs.androidx.navigation3.runtime) + + // If using the ViewModel add-on library + implementation(libs.androidx.lifecycle.viewmodel.navigation3) + } + +Also update the project's `minSdk` to 23 and the `compileSdk` to 36. You usually +find these in `app/build.gradle.kts` or `lib.versions.toml`. + +## Step 2: Update navigation routes to implement the `NavKey` interface + +Update every navigation [route](https://developer.android.com/guide/navigation#types) so that it implements the `NavKey` +interface. This lets you use `rememberNavBackStack` to assist with [saving your +navigation state](https://developer.android.com/guide/navigation/navigation-3/save-state). + +Before: + + @Serializable data object RouteA + +After: + + @Serializable data object RouteA : NavKey + +> [!NOTE] +> **Note:** The `@Serializable` annotation is provided by the KotlinX Serialization plugin. You can add this by following [these project setup steps](https://developer.android.com/guide/navigation/navigation-3/get-started#project-setup). + +## Step 3: Create classes to hold and modify your navigation state + +### Step 3.1: Create a navigation state holder + +Copy the following code into a file named `NavigationState.kt`. Add your package +name to match your project structure. + + // package com.example.project + + import androidx.compose.runtime.Composable + import androidx.compose.runtime.MutableState + import androidx.compose.runtime.getValue + import androidx.compose.runtime.mutableStateOf + import androidx.compose.runtime.remember + import androidx.compose.runtime.saveable.rememberSerializable + import androidx.compose.runtime.setValue + import androidx.compose.runtime.snapshots.SnapshotStateList + import androidx.compose.runtime.toMutableStateList + import androidx.navigation3.runtime.NavBackStack + import androidx.navigation3.runtime.NavEntry + import androidx.navigation3.runtime.NavKey + import androidx.navigation3.runtime.rememberDecoratedNavEntries + import androidx.navigation3.runtime.rememberNavBackStack + import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator + import androidx.navigation3.runtime.serialization.NavKeySerializer + import androidx.savedstate.compose.serialization.serializers.MutableStateSerializer + + /** + * Create a navigation state that persists config changes and process death. + */ + @Composable + fun rememberNavigationState( + startRoute: NavKey, + topLevelRoutes: Set + ): NavigationState { + + val topLevelRoute = rememberSerializable( + startRoute, topLevelRoutes, + serializer = MutableStateSerializer(NavKeySerializer()) + ) { + mutableStateOf(startRoute) + } + + val backStacks = topLevelRoutes.associateWith { key -> rememberNavBackStack(key) } + + return remember(startRoute, topLevelRoutes) { + NavigationState( + startRoute = startRoute, + topLevelRoute = topLevelRoute, + backStacks = backStacks + ) + } + } + + /** + * State holder for navigation state. + * + * @param startRoute - the start route. The user will exit the app through this route. + * @param topLevelRoute - the current top level route + * @param backStacks - the back stacks for each top level route + */ + class NavigationState( + val startRoute: NavKey, + topLevelRoute: MutableState, + val backStacks: Map> + ) { + var topLevelRoute: NavKey by topLevelRoute + val stacksInUse: List + get() = if (topLevelRoute == startRoute) { + listOf(startRoute) + } else { + listOf(startRoute, topLevelRoute) + } + } + + /** + * Convert NavigationState into NavEntries. + */ + @Composable + fun NavigationState.toEntries( + entryProvider: (NavKey) -> NavEntry + ): SnapshotStateList> { + + val decoratedEntries = backStacks.mapValues { (_, stack) -> + val decorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + ) + rememberDecoratedNavEntries( + backStack = stack, + entryDecorators = decorators, + entryProvider = entryProvider + ) + } + + return stacksInUse + .flatMap { decoratedEntries[it] ?: emptyList() } + .toMutableStateList() + } + +**AI Agent** : `rememberSerializable` is correct. Do not change it to +`rememberSaveable`. + +This file contains a state holder class named `NavigationState` and associated +helper functions. It holds a set of top-level routes, each with its own back +stack. Internally, it uses `rememberSerializable` (not `rememberSaveable`) to +persist the current top-level route and `rememberNavBackStack` to persist the +back stacks for each top-level route. + +### Step 3.2: Create an object that modifies navigation state in response to events + +Copy the following code into a file named `Navigator.kt`. Add your package name +to match your project structure. + + // package com.example.project + + import androidx.navigation3.runtime.NavKey + + /** + * Handles navigation events (forward and back) by updating the navigation state. + */ + class Navigator(val state: NavigationState){ + fun navigate(route: NavKey){ + if (route in state.backStacks.keys){ + // This is a top level route, just switch to it. + state.topLevelRoute = route + } else { + state.backStacks[state.topLevelRoute]?.add(route) + } + } + + fun goBack(){ + val currentStack = state.backStacks[state.topLevelRoute] ?: + error("Stack for ${state.topLevelRoute} not found") + val currentRoute = currentStack.last() + + // If we're at the base of the current route, go back to the start route stack. + if (currentRoute == state.topLevelRoute){ + state.topLevelRoute = state.startRoute + } else { + currentStack.removeLastOrNull() + } + } + } + +The `Navigator` class provides two navigation event methods: + +- `navigate` to a specific route. +- `goBack` from the current route. + +Both methods modify the `NavigationState`. + +> [!IMPORTANT] +> **Architecture principles:** These classes follow the principles of [Unidirectional Data Flow](https://developer.android.com/topic/architecture): +> +> - The `Navigator` handles navigation events and uses them to update `NavigationState`. +> - The UI (provided by `NavDisplay`) observes `NavigationState` and reacts to any changes in that state by updating its UI. + +### Step 3.3: Create the `NavigationState` and `Navigator` + +Create instances of `NavigationState` and `Navigator` with the same scope as +your `NavController`. + + val navigationState = rememberNavigationState( + startRoute = , + topLevelRoutes = + ) + + val navigator = remember { Navigator(navigationState) } + +## Step 4: Replace `NavController` + +Replace `NavController` navigation event methods with `Navigator` equivalents. + +| **`NavController` field or method** | **`Navigator` equivalent** | +|---|---| +| `navigate()` | `navigate()` | +| `popBackStack()` | `goBack()` | + +Replace `NavController` fields with `NavigationState` fields. + +| **`NavController` field or method** | **`NavigationState` equivalent** | +|---|---| +| `currentBackStack` | `backStacks[topLevelRoute]` | +| `currentBackStackEntry` `currentBackStackEntryAsState()` `currentBackStackEntryFlow` `currentDestination` | `backStacks[topLevelRoute].last()` | +| Get the top level route: Traverse up the hierarchy from the current back stack entry to find it. | `topLevelRoute` | + +Use `NavigationState.topLevelRoute` to determine the item that is currently +selected in a navigation bar. + +Before: + + val isSelected = navController.currentBackStackEntryAsState().value?.destination.isRouteInHierarchy(key::class) + + fun NavDestination?.isRouteInHierarchy(route: KClass<*>) = + this?.hierarchy?.any { + it.hasRoute(route) + } ?: false + +After: + + val isSelected = key == navigationState.topLevelRoute + +Verify that you have removed all references to `NavController`, including +any imports. + +## Step 5: Move your destinations from `NavHost`'s `NavGraph` into an `entryProvider` + +In Navigation 2, you [define your destinations](https://developer.android.com/guide/navigation/design#compose) +using the [NavGraphBuilder DSL](https://developer.android.com/guide/navigation/design/kotlin-dsl#navgraphbuilder), +usually inside `NavHost`'s trailing lambda. It is common to use extension +functions here as described in [Encapsulate your navigation code](https://developer.android.com/guide/navigation/design/encapsulate). + +In Navigation 3, you define your destinations using an `entryProvider`. This +`entryProvider` resolves a route to a [`NavEntry`](https://developer.android.com/guide/navigation/navigation-3/basics#resolve-keys). Importantly, the +`entryProvider` does not define parent-child relationships between entries. + +In this migration guide, parent-child relationships are modelled +as follows: + +- `NavigationState` has a set of top-level routes (the parent routes) and a stack for each one. It keeps track of the current top-level route and its associated stack. +- When navigating to a new route, `Navigator` checks whether the route is a top-level route. If it is, the current top-level route and stack are updated. If it's not, it's a child route and is added to the current stack. + +> [!NOTE] +> **Note:** If your app needs to navigate from an entry in one stack to another, you need to define the parent-child relationships for the routes and update the navigation logic in `Navigator` to support this. + +## Step 5.1: Create an `entryProvider` + +Create an `entryProvider` [using the DSL](https://developer.android.com/guide/navigation/navigation-3/basics#entry-provider-DSL) at the same scope as the +`NavigationState`. + + val entryProvider = entryProvider { + + } + +## Step 5.2: Move destinations into the `entryProvider` + +For each destination defined inside `NavHost`, do the following based on the +destination type: + +- `navigation`: Delete it along with the route. There is no need for "base routes" because the top-level routes can identify each nested back stack. +- `composable`: Move it into `entryProvider` and rename it to `entry`, retaining the type parameter. For example, `composable` becomes `entry`. +- `dialog`: Do the same as `composable`, but add metadata to the entry as follows: `entry(metadata = DialogSceneStrategy.dialog())`. +- [`bottomSheet`](https://developer.android.com/reference/kotlin/androidx/compose/material/navigation/package-summary#(androidx.navigation.NavGraphBuilder).bottomSheet(kotlin.String,kotlin.collections.List,kotlin.collections.List,kotlin.Function2)): [Follow the bottom sheet recipe here](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/bottomsheet). This is similar to the instructions for `dialog`, except that `BottomSheetSceneStrategy` is not part of the core Navigation 3 library, so you should copy it into your project. + +**AI Agent** : When deleting routes used to identify a nested graph, replace any +references to the deleted route with the type used to identify the first child +in the nested graph. For example if the original code is +`navigation{ composable{ ... } }`, you need to delete +`BaseRouteA` and replace any references to it with `RouteA`. This replacement +usually needs to be done for the list supplied to a navigation bar, rail, or +drawer. + +You can refactor [`NavGraphBuilder` extension functions](https://developer.android.com/guide/navigation/design/encapsulate) to +`EntryProviderScope` extension functions, and then move them. + +Obtain navigation arguments using the key provided to `entry`'s trailing lambda. + +For example: + + import androidx.navigation.NavDestination + import androidx.navigation.NavDestination.Companion.hasRoute + import androidx.navigation.NavDestination.Companion.hierarchy + import androidx.navigation.NavGraphBuilder + import androidx.navigation.compose.NavHost + import androidx.navigation.compose.composable + import androidx.navigation.compose.currentBackStackEntryAsState + import androidx.navigation.compose.dialog + import androidx.navigation.compose.navigation + import androidx.navigation.compose.rememberNavController + import androidx.navigation.navOptions + import androidx.navigation.toRoute + + @Serializable data object BaseRouteA + @Serializable data class RouteA(val id: String) + @Serializable data object BaseRouteB + @Serializable data object RouteB + @Serializable data object RouteD + + NavHost(navController = navController, startDestination = BaseRouteA){ + composable{ + val id = entry.toRoute().id + ScreenA(title = "Screen has ID: $id") + } + featureBSection() + dialog{ ScreenD() } + } + + fun NavGraphBuilder.featureBSection() { + navigation(startDestination = RouteB) { + composable { ScreenB() } + } + } + +becomes: + + import androidx.navigation3.runtime.EntryProviderScope + import androidx.navigation3.runtime.NavKey + import androidx.navigation3.runtime.entryProvider + import androidx.navigation3.scene.DialogSceneStrategy + + @Serializable data class RouteA(val id: String) : NavKey + @Serializable data object RouteB : NavKey + @Serializable data object RouteD : NavKey + + val entryProvider = entryProvider { + entry{ key -> ScreenA(title = "Screen has ID: ${key.id}") } + featureBSection() + entry(metadata = DialogSceneStrategy.dialog()){ ScreenD() } + } + + fun EntryProviderScope.featureBSection() { + entry { ScreenB() } + } + +## Step 6: Replace `NavHost` with `NavDisplay` + +Replace `NavHost` with `NavDisplay`. + +- Delete `NavHost` and replace it with `NavDisplay`. +- Specify `entries = navigationState.toEntries(entryProvider)` as a parameter. This converts the navigation state into the entries that `NavDisplay` shows using the `entryProvider`. +- Connect `NavDisplay.onBack` to `navigator.goBack()`. This causes `navigator` to update the navigation state when `NavDisplay`'s built-in back handler completes. +- If you have dialog destinations, add `DialogSceneStrategy` to `NavDisplay`'s `sceneStrategies` parameter. + +For example: + + import androidx.navigation3.ui.NavDisplay + + NavDisplay( + entries = navigationState.toEntries(entryProvider), + onBack = { navigator.goBack() }, + sceneStrategies = remember { listOf(DialogSceneStrategy()) } + ) + +## Step 7: Remove Navigation 2 dependencies + +Remove all Navigation 2 imports and library dependencies. + +## Summary + +Congratulations! Your project is now migrated to Navigation 3. If you or your AI +agent has run into any problems using this guide, [file a bug +here](https://issuetracker.google.com/issues/new?component=1750212&template=2102223&title=%5BMigration%5D). \ No newline at end of file diff --git a/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/animations.md b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/animations.md new file mode 100644 index 00000000..5e15bd46 --- /dev/null +++ b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/animations.md @@ -0,0 +1,147 @@ +# Animations Recipe + +This recipe shows how to override the default animations at the `NavDisplay` level, and at the individual destination level. + +## How it works + +The `NavDisplay` composable takes `transitionSpec`, `popTransitionSpec`, and `predictivePopTransitionSpec` parameters to define the animations for forward, backward, and predictive back navigation respectively. These animations will be applied to all destinations by default. + +In this example, we use `slideInHorizontally` and `slideOutHorizontally` to create a sliding animation for forward and backward navigation. + +It is also possible to override these animations for a specific destination by providing a different `transitionSpec` and `popTransitionSpec` to the `entry` composable. In this recipe, `ScreenC` has a custom vertical slide animation. +[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/animations) + +``` +package com.example.nav3recipes.animations + + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.metadata +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.content.ContentGreen +import com.example.nav3recipes.content.ContentMauve +import com.example.nav3recipes.content.ContentOrange +import com.example.nav3recipes.ui.setEdgeToEdgeConfig +import kotlinx.serialization.Serializable + + +@Serializable +private data object ScreenA : NavKey + +@Serializable +private data object ScreenB : NavKey + +@Serializable +private data object ScreenC : NavKey + + +class AnimatedActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + setEdgeToEdgeConfig() + super.onCreate(savedInstanceState) + setContent { + + val backStack = rememberNavBackStack(ScreenA) + + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryProvider = entryProvider { + entry { + ContentOrange("This is Screen A") { + Button(onClick = dropUnlessResumed { backStack.add(ScreenB) }) { + Text("Go to Screen B") + } + } + } + entry { + ContentMauve("This is Screen B") { + Button(onClick = dropUnlessResumed { backStack.add(ScreenC) }) { + Text("Go to Screen C") + } + } + } + entry( + metadata = metadata { + // Slide new content up, keeping the old content in place underneath + put(NavDisplay.TransitionKey) { + slideInVertically( + initialOffsetY = { it }, + animationSpec = tween(1000) + ) togetherWith ExitTransition.KeepUntilTransitionsFinished + } + + // Slide old content down, revealing the new content in place underneath + put(NavDisplay.PopTransitionKey) { + EnterTransition.None togetherWith + slideOutVertically( + targetOffsetY = { it }, + animationSpec = tween(1000) + ) + } + + // Slide old content down, revealing the new content in place underneath + put(NavDisplay.PredictivePopTransitionKey) { + EnterTransition.None togetherWith + slideOutVertically( + targetOffsetY = { it }, + animationSpec = tween(1000) + ) + } + } + ) { + ContentGreen("This is Screen C") + } + }, + transitionSpec = { + // Slide in from right when navigating forward + slideInHorizontally( + initialOffsetX = { it }, + animationSpec = tween(1000) + ) togetherWith slideOutHorizontally( + targetOffsetX = { -it }, + animationSpec = tween(1000) + ) + }, + popTransitionSpec = { + // Slide in from left when navigating back + slideInHorizontally( + initialOffsetX = { -it }, + animationSpec = tween(1000) + ) togetherWith slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = tween(1000) + ) + }, + predictivePopTransitionSpec = { + // Slide in from left when navigating back + slideInHorizontally( + initialOffsetX = { -it }, + animationSpec = tween(1000) + ) togetherWith slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = tween(1000) + ) + } + ) + } + } +} +``` \ No newline at end of file diff --git a/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/basic.md b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/basic.md new file mode 100644 index 00000000..087d5cf9 --- /dev/null +++ b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/basic.md @@ -0,0 +1,89 @@ +# Basic Recipe + +This recipe shows a basic example of how to use the Navigation 3 API with two screens. + +## How it works + +This example defines two routes: `RouteA` and `RouteB`. `RouteA` is a `data object` representing a simple screen, while `RouteB` is a `data class` that takes an `id` as a parameter. + +A `mutableStateListOf` is used to manage the navigation back stack. + +The `NavDisplay` composable is used to display the current screen. Its `entryProvider` parameter is a lambda that takes a route from the back stack and returns a `NavEntry`. Inside the `entryProvider`, a `when` statement is used to determine which composable to display based on the route. + +To navigate from `RouteA` to `RouteB`, we simply add a `RouteB` instance to the back stack. The `id` is passed as an argument to the `RouteB` data class. +[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/basic) + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.basic + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.content.ContentBlue +import com.example.nav3recipes.content.ContentGreen +import com.example.nav3recipes.ui.setEdgeToEdgeConfig + +private data object RouteA + +private data class RouteB(val id: String) + +class BasicActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + setEdgeToEdgeConfig() + super.onCreate(savedInstanceState) + setContent { + val backStack = remember { mutableStateListOf(RouteA) } + + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryProvider = { key -> + when (key) { + is RouteA -> NavEntry(key) { + ContentGreen("Welcome to Nav3") { + Button(onClick = dropUnlessResumed { + backStack.add(RouteB("123")) + }) { + Text("Click to navigate") + } + } + } + + is RouteB -> NavEntry(key) { + ContentBlue("Route id: ${key.id} ") + } + + else -> { + error("Unknown route: $key") + } + } + } + ) + } + } +} +``` \ No newline at end of file diff --git a/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/basicdsl.md b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/basicdsl.md new file mode 100644 index 00000000..2067c086 --- /dev/null +++ b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/basicdsl.md @@ -0,0 +1,85 @@ +# Basic DSL Recipe + +This recipe shows a basic example of how to use the Navigation 3 API with two screens, using the `entryProvider` DSL and a persistent back stack. + +## How it works + +This example is similar to the basic recipe, but with a few key differences: + +1. **Persistent Back Stack** : It uses `rememberNavBackStack(RouteA)` to create and remember the back stack. This makes the back stack persistent across configuration changes (e.g., screen rotation). To use `rememberNavBackStack`, the navigation keys must be serializable, which is why `RouteA` and `RouteB` are annotated with `@Serializable` and implement the `NavKey` interface. + +2. **`entryProvider` DSL** : Instead of a `when` statement, this example uses the `entryProvider` DSL to define the content for each route. The `entry` function is used to associate a route type with its composable content. + +The navigation logic remains the same: to navigate from `RouteA` to `RouteB`, we add a `RouteB` instance to the back stack. +[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/basicdsl) + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.basicdsl + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.content.ContentBlue +import com.example.nav3recipes.content.ContentGreen +import com.example.nav3recipes.ui.setEdgeToEdgeConfig +import kotlinx.serialization.Serializable + +@Serializable +private data object RouteA : NavKey + +@Serializable +private data class RouteB(val id: String) : NavKey + +class BasicDslActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + setEdgeToEdgeConfig() + super.onCreate(savedInstanceState) + setContent { + val backStack = rememberNavBackStack(RouteA) + + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryProvider = entryProvider { + entry { + ContentGreen("Welcome to Nav3") { + Button(onClick = dropUnlessResumed { + backStack.add(RouteB("123")) + }) { + Text("Click to navigate") + } + } + } + entry { key -> + ContentBlue("Route id: ${key.id} ") + } + } + ) + } + } +} +``` \ No newline at end of file diff --git a/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/basicsaveable.md b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/basicsaveable.md new file mode 100644 index 00000000..fd2a72d1 --- /dev/null +++ b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/basicsaveable.md @@ -0,0 +1,90 @@ +# Basic Saveable Recipe + +This recipe shows a basic example of how to create a persistent back stack that survives configuration changes. + +## How it works + +To make the back stack persistent, we use the `rememberNavBackStack` function. This function creates and remembers the back stack across configuration changes (e.g., screen rotation). + +A requirement for using `rememberNavBackStack` is that the navigation keys (routes) must be serializable. In this example, `RouteA` and `RouteB` are annotated with `@Serializable` and implement the `NavKey` interface. + +This example uses a `when` statement within the `entryProvider` to map routes to their corresponding composables, but it could also be used with the `entryProvider` DSL. +[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/basicsaveable) + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.basicsaveable + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.content.ContentBlue +import com.example.nav3recipes.content.ContentGreen +import com.example.nav3recipes.ui.setEdgeToEdgeConfig +import kotlinx.serialization.Serializable + +@Serializable +private data object RouteA : NavKey + +@Serializable +private data class RouteB(val id: String) : NavKey + +class BasicSaveableActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + setEdgeToEdgeConfig() + super.onCreate(savedInstanceState) + setContent { + val backStack = rememberNavBackStack(RouteA) + + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryProvider = { key -> + when (key) { + is RouteA -> NavEntry(key) { + ContentGreen("Welcome to Nav3") { + Button(onClick = dropUnlessResumed { + backStack.add(RouteB("123")) + }) { + Text("Click to navigate") + } + } + } + + is RouteB -> NavEntry(key) { + ContentBlue("Route id: ${key.id} ") + } + + else -> { + error("Unknown route: $key") + } + } + } + ) + } + } +} +``` \ No newline at end of file diff --git a/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/bottomsheet.md b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/bottomsheet.md new file mode 100644 index 00000000..aa6b456a --- /dev/null +++ b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/bottomsheet.md @@ -0,0 +1,195 @@ +# Bottom Sheet Recipe + +This recipe demonstrates how to display a destination as a modal bottom sheet. + +## How it works + +To show a destination as a bottom sheet, you need to do two things: + +1. **Use `BottomSheetSceneStrategy`** : Create an instance of `BottomSheetSceneStrategy` and pass it to the `sceneStrategy` parameter of the `NavDisplay` composable. + +2. **Add metadata to the destination** : For the destination that you want to display as a bottom sheet, add `BottomSheetSceneStrategy.bottomSheet()` to its metadata. This is done in the `entry` function. + +In this example, `RouteB` is configured to be a bottom sheet. When you navigate from `RouteA` to `RouteB`, `RouteB` will be displayed in a modal bottom sheet that slides up from the bottom of the screen. + +The content of the bottom sheet can be styled as needed. In this recipe, the content is clipped to have rounded corners. + +For more information, see the official documentation on [custom layouts](https://developer.android.com/guide/navigation/navigation-3/custom-layouts). +[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/bottomsheet) + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.bottomsheet + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.content.ContentBlue +import com.example.nav3recipes.content.ContentGreen +import com.example.nav3recipes.ui.setEdgeToEdgeConfig +import kotlinx.serialization.Serializable + +@Serializable +private data object RouteA : NavKey + +@Serializable +private data class RouteB(val id: String) : NavKey + +class BottomSheetActivity : ComponentActivity() { + + @OptIn(ExperimentalMaterial3Api::class) + override fun onCreate(savedInstanceState: Bundle?) { + setEdgeToEdgeConfig() + super.onCreate(savedInstanceState) + setContent { + val backStack = rememberNavBackStack(RouteA) + val bottomSheetStrategy = remember { BottomSheetSceneStrategy() } + + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + sceneStrategies = listOf(bottomSheetStrategy), + entryProvider = entryProvider { + entry { + ContentGreen("Welcome to Nav3") { + Button(onClick = dropUnlessResumed { + backStack.add(RouteB("123")) + }) { + Text("Click to open bottom sheet") + } + } + } + entry( + metadata = BottomSheetSceneStrategy.bottomSheet() + ) { key -> + ContentBlue( + title = "Route id: ${key.id}", + modifier = Modifier.clip( + shape = RoundedCornerShape(16.dp) + ) + ) + } + } + ) + } + } +} +``` + +``` +package com.example.nav3recipes.bottomsheet + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ModalBottomSheetProperties +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.rememberLifecycleOwner +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavMetadataKey +import androidx.navigation3.runtime.get +import androidx.navigation3.runtime.metadata +import androidx.navigation3.scene.OverlayScene +import androidx.navigation3.scene.Scene +import androidx.navigation3.scene.SceneStrategy +import androidx.navigation3.scene.SceneStrategyScope +import com.example.nav3recipes.bottomsheet.BottomSheetSceneStrategy.Companion.bottomSheet + +/** An [OverlayScene] that renders an [entry] within a [ModalBottomSheet]. */ +@OptIn(ExperimentalMaterial3Api::class) +internal data class BottomSheetScene( + override val key: T, + override val previousEntries: List>, + override val overlaidEntries: List>, + private val entry: NavEntry, + private val modalBottomSheetProperties: ModalBottomSheetProperties, + private val onBack: () -> Unit, +) : OverlayScene { + + override val entries: List> = listOf(entry) + + override val content: @Composable (() -> Unit) = { + val lifecycleOwner = rememberLifecycleOwner() + ModalBottomSheet( + onDismissRequest = onBack, + properties = modalBottomSheetProperties, + ) { + CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) { + entry.Content() + } + } + } +} + +/** + * A [SceneStrategy] that displays entries that have added [bottomSheet] to their [NavEntry.metadata] + * within a [ModalBottomSheet] instance. + * + * This strategy should always be added before any non-overlay scene strategies. + */ +@OptIn(ExperimentalMaterial3Api::class) +class BottomSheetSceneStrategy : SceneStrategy { + + override fun SceneStrategyScope.calculateScene(entries: List>): Scene? { + val lastEntry = entries.lastOrNull() ?: return null + val bottomSheetProperties = lastEntry.metadata[BottomSheetKey] ?: return null + return bottomSheetProperties.let { properties -> + @Suppress("UNCHECKED_CAST") + BottomSheetScene( + key = lastEntry.contentKey as T, + previousEntries = entries.dropLast(1), + overlaidEntries = entries.dropLast(1), + entry = lastEntry, + modalBottomSheetProperties = properties, + onBack = onBack + ) + } + } + + companion object { + /** + * Function to be called on the [NavEntry.metadata] to mark this entry as something that + * should be displayed within a [ModalBottomSheet]. + * + * @param modalBottomSheetProperties properties that should be passed to the containing + * [ModalBottomSheet]. + */ + fun bottomSheet(modalBottomSheetProperties: ModalBottomSheetProperties = ModalBottomSheetProperties()) = + metadata { + put(BottomSheetKey, modalBottomSheetProperties) + } + + object BottomSheetKey : NavMetadataKey + } + +} +``` \ No newline at end of file diff --git a/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/common-ui.md b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/common-ui.md new file mode 100644 index 00000000..d49b399e --- /dev/null +++ b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/common-ui.md @@ -0,0 +1,200 @@ +# Common UI Recipe + +This recipe demonstrates how to implement a common navigation UI pattern with a bottom navigation bar and multiple back stacks, where each tab in the navigation bar has its own navigation history. + +## How it works + +This example has three top-level destinations: `Home`, `ChatList`, and `Camera`. The `ChatList` destination also has a sub-route, `ChatDetail`. + +### `TopLevelBackStack` + +The core of this recipe is the `TopLevelBackStack` class, which is responsible for managing the navigation state. It works as follows: + +- It maintains a separate back stack for each top-level destination (tab). +- It keeps track of the currently selected top-level destination. +- It provides a single, flattened back stack that can be used by the `NavDisplay` composable. This flattened back stack is a combination of the individual back stacks of all the tabs. + +### UI Structure + +The UI is built using a `Scaffold` composable, with a `NavigationBar` as the `bottomBar`. + +- The `NavigationBar` displays an item for each top-level destination. When an item is clicked, it calls `topLevelBackStack.addTopLevel` to switch to the corresponding tab, preserving the navigation history of each tab. +- The `NavDisplay` composable is placed in the content area of the `Scaffold`. It is responsible for displaying the current screen based on the flattened back stack provided by `TopLevelBackStack`. + +This approach allows for a common navigation pattern where users can switch between different sections of the app, and each section maintains its own navigation history. + +### State Preservation + +It's important to note how the navigation state is managed in this recipe. When a user navigates away from a top-level destination (e.g., by pressing the back button until they return to a previous tab), the entire navigation history for that destination is cleared. The state is not saved. When the user returns to that tab later, they will start from its initial screen. + +**Note** : In this example, the `Home` route can move above the `ChatList` and `Camera` routes, meaning navigating back from `Home` doesn't necessarily leave the app. The app will exit when the user goes back from a single remaining top level route in the back stack. +[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/commonui) + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.commonui + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Face +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.content.ContentBlue +import com.example.nav3recipes.content.ContentGreen +import com.example.nav3recipes.content.ContentPurple +import com.example.nav3recipes.content.ContentRed +import com.example.nav3recipes.ui.setEdgeToEdgeConfig + +private sealed interface TopLevelRoute { + val icon: ImageVector +} +private data object Home : TopLevelRoute { override val icon = Icons.Default.Home } +private data object ChatList : TopLevelRoute { override val icon = Icons.Default.Face } +private data object ChatDetail +private data object Camera : TopLevelRoute { override val icon = Icons.Default.PlayArrow } + +private val TOP_LEVEL_ROUTES : List = listOf(Home, ChatList, Camera) + +class CommonUiActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + setEdgeToEdgeConfig() + super.onCreate(savedInstanceState) + setContent { + val topLevelBackStack = remember { TopLevelBackStack(Home) } + + Scaffold( + bottomBar = { + NavigationBar { + TOP_LEVEL_ROUTES.forEach { topLevelRoute -> + + val isSelected = topLevelRoute == topLevelBackStack.topLevelKey + NavigationBarItem( + selected = isSelected, + onClick = { + topLevelBackStack.addTopLevel(topLevelRoute) + }, + icon = { + Icon( + imageVector = topLevelRoute.icon, + contentDescription = null + ) + } + ) + } + } + } + ) { _ -> + NavDisplay( + backStack = topLevelBackStack.backStack, + onBack = { topLevelBackStack.removeLast() }, + entryProvider = entryProvider { + entry{ + ContentRed("Home screen") + } + entry{ + ContentGreen("Chat list screen"){ + Button(onClick = dropUnlessResumed { + topLevelBackStack.add(ChatDetail) + }) { + Text("Go to conversation") + } + } + } + entry{ + ContentBlue("Chat detail screen") + } + entry{ + ContentPurple("Camera screen") + } + }, + ) + } + } + } +} + +class TopLevelBackStack(startKey: T) { + + // Maintain a stack for each top level route + private var topLevelStacks : LinkedHashMap> = linkedMapOf( + startKey to mutableStateListOf(startKey) + ) + + // Expose the current top level route for consumers + var topLevelKey by mutableStateOf(startKey) + private set + + // Expose the back stack so it can be rendered by the NavDisplay + val backStack = mutableStateListOf(startKey) + + private fun updateBackStack() = + backStack.apply { + clear() + addAll(topLevelStacks.flatMap { it.value }) + } + + fun addTopLevel(key: T){ + + // If the top level doesn't exist, add it + if (topLevelStacks[key] == null){ + topLevelStacks.put(key, mutableStateListOf(key)) + } else { + // Otherwise just move it to the end of the stacks + topLevelStacks.apply { + remove(key)?.let { + put(key, it) + } + } + } + topLevelKey = key + updateBackStack() + } + + fun add(key: T){ + topLevelStacks[topLevelKey]?.add(key) + updateBackStack() + } + + fun removeLast(){ + val removedKey = topLevelStacks[topLevelKey]?.removeLastOrNull() + // If the removed key was a top level key, remove the associated top level stack + topLevelStacks.remove(removedKey) + topLevelKey = topLevelStacks.keys.last() + updateBackStack() + } +} + +``` \ No newline at end of file diff --git a/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/conditional.md b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/conditional.md new file mode 100644 index 00000000..7ff23dbd --- /dev/null +++ b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/conditional.md @@ -0,0 +1,228 @@ +# Conditional Navigation Recipe + +This recipe demonstrates how to implement conditional navigation, where certain destinations are only accessible if a condition is met (in this case, if the user is logged in). + +## How it works + +This example has a `Profile` destination that requires the user to be logged in. If the user is not logged in and attempts to navigate to `Profile`, they are redirected to a `Login` screen. After a successful login, they are automatically navigated to the `Profile` screen. + +### `AppBackStack` + +The core of this recipe is the custom `AppBackStack` class, which encapsulates the logic for conditional navigation. + +- **`RequiresLogin` interface** : A marker interface, `RequiresLogin`, is used to identify destinations that require the user to be logged in. The `Profile` destination implements this interface. + +- **Redirecting to Login** : When the `add` function is called with a destination that implements `RequiresLogin` and the user is not logged in, `AppBackStack` stores the intended destination and adds the `Login` route to the back stack instead. + +- **Handling Login** : When the `login` function is called, it sets the user's status to logged in. If there is a stored destination that the user was trying to access, it adds that destination to the back stack and removes the `Login` screen. + +- **Handling Logout** : When the `logout` function is called, it sets the user's status to logged out and removes any destinations from the back stack that require the user to be logged in. + +This approach provides a clean way to handle conditional navigation by centralizing the logic in a custom back stack implementation. +[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/conditional) + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.conditional + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSerializable +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.serialization.NavBackStackSerializer +import androidx.navigation3.runtime.serialization.NavKeySerializer +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.content.ContentBlue +import com.example.nav3recipes.content.ContentGreen +import com.example.nav3recipes.content.ContentYellow +import kotlinx.serialization.Serializable + + +/** + * Class for representing navigation keys in the app. + * + * Note: We use a sealed class because KotlinX Serialization handles + * polymorphic serialization of sealed classes automatically. + * + * @param requiresLogin - true if the navigation key requires that the user is logged in + * to navigate to it + */ +@Serializable +sealed class ConditionalNavKey(val requiresLogin: Boolean = false) : NavKey + +/** + * Key representing home screen + */ +@Serializable +private data object Home : ConditionalNavKey() + +/** + * Key representing profile screen that is only accessible once the user has logged in + */ +@Serializable +private data object Profile : ConditionalNavKey(requiresLogin = true) + +/** + * Key representing login screen + * + * @param redirectToKey - navigation key to redirect to after successful login + */ +@Serializable +private data class Login( + val redirectToKey: ConditionalNavKey? = null +) : ConditionalNavKey() + +class ConditionalActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + + val backStack = rememberNavBackStack(Home) + var isLoggedIn by rememberSaveable { + mutableStateOf(false) + } + val navigator = remember { + Navigator( + backStack = backStack, + onNavigateToRestrictedKey = { redirectToKey -> Login(redirectToKey) }, + isLoggedIn = { isLoggedIn } + ) + } + + NavDisplay( + backStack = backStack, + onBack = { navigator.goBack() }, + entryProvider = entryProvider { + entry { + ContentGreen("Welcome to Nav3. Logged in? ${isLoggedIn}") { + Column { + Button(onClick = dropUnlessResumed { navigator.navigate(Profile) }) { + Text("Profile") + } + Button(onClick = dropUnlessResumed { navigator.navigate(Login()) }) { + Text("Login") + } + } + } + } + entry { + ContentBlue("Profile screen (only accessible once logged in)") { + Button(onClick = dropUnlessResumed { + isLoggedIn = false + navigator.navigate(Home) + }) { + Text("Logout") + } + } + } + entry { key -> + ContentYellow("Login screen. Logged in? $isLoggedIn") { + Button(onClick = dropUnlessResumed { + isLoggedIn = true + key.redirectToKey?.let { targetKey -> + backStack.remove(key) + navigator.navigate(targetKey) + } + }) { + Text("Login") + } + } + } + } + ) + } + } +} + + +// An overload of `rememberNavBackStack` that returns a subtype of `NavKey`. +// See https://issuetracker.google.com/issues/463382671 for a discussion of this function +@Composable +fun rememberNavBackStack(vararg elements: T): NavBackStack { + return rememberSerializable( + serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer()) + ) { + NavBackStack(*elements) + } +} +``` + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.conditional + +import androidx.navigation3.runtime.NavBackStack + +/** + * Provides navigation events with built-in support for conditional access. If the user attempts to + * navigate to a [ConditionalNavKey] that requires login ([ConditionalNavKey.requiresLogin] is true) + * but is not currently logged in, the Navigator will redirect the user to a login key. + * + * @property backStack The back stack that is modified by this class + * @property onNavigateToRestrictedKey A lambda that is called when the user attempts to navigate + * to a key that requires login. This should return the key that represents the login screen. The + * user's target key is supplied as a parameter so that after successful login the user can be + * redirected to their target destination. + * @property isLoggedIn A lambda that returns whether the user is logged in. + */ +class Navigator( + private val backStack: NavBackStack, + private val onNavigateToRestrictedKey: (targetKey: ConditionalNavKey?) -> ConditionalNavKey, + private val isLoggedIn: () -> Boolean, +) { + fun navigate(key: ConditionalNavKey) { + if (key.requiresLogin && !isLoggedIn()) { + val loginKey = onNavigateToRestrictedKey(key) + backStack.add(loginKey) + } else { + backStack.add(key) + } + } + + fun goBack() = backStack.removeLastOrNull() +} +``` \ No newline at end of file diff --git a/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-advanced.md b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-advanced.md new file mode 100644 index 00000000..62a9439b --- /dev/null +++ b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-advanced.md @@ -0,0 +1,153 @@ +# Deep Link Advanced Recipe + +This recipe demonstrates how to apply the principles of navigation in the context of deep links by +managing a synthetic backStack and Task stacks. + +# Recipe Structure + +This recipe simulates a real-world scenario where "App A" deeplinks +into "App B". + +"App A" is simulated by the module [com.example.nav3recipes.deeplink.advanced](https://developer.android.com/app/src/main/java/com/example/nav3recipes/deeplink/advanced), which +contains the `CreateAdvancedDeepLinkActivity` that allows you to create a deeplink intent and +trigger that in either the existing Task, or in a new Task. + +"App B" is simulated by the module [advanceddeeplinkapp](https://developer.android.com/advanceddeeplinkapp/src/main/java/com/example/nav3recipes/deeplink/advanced), which contains +the MainActivity that you deeplink into. That module shows you how to build a synthetic backStack +and how to manage the Task stack properly in order to support both Back and Up buttons. + +# Core implementation + +The core helper functions for navigateUp and building synthetic backStack can be +found [here](https://developer.android.com/static/advanceddeeplinkapp/src/main/java/com/example/nav3recipes/deeplink/advanced/util/DeepLinkBackStackUtil.kt) + +# Further Read + +Check out the [deep link guide](https://developer.android.com/docs/deeplink-guide) for a +comprehensive guide on Deep linking principles and how to apply them in Navigation 3. +[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/deeplink/advanced) + +``` +package com.example.nav3recipes.deeplink.advanced + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.core.net.toUri +import androidx.lifecycle.compose.dropUnlessResumed +import com.example.nav3recipes.common.deeplink.EntryScreen +import com.example.nav3recipes.common.deeplink.LIST_FIRST_NAMES +import com.example.nav3recipes.common.deeplink.LIST_LOCATIONS +import com.example.nav3recipes.common.deeplink.MenuDropDown +import com.example.nav3recipes.common.deeplink.PaddedButton +import com.example.nav3recipes.common.deeplink.TextContent + +internal const val ADVANCED_PATH_BASE = "https://www.nav3deeplink.com" + +/** + * The recipe entry point that allows users to create a deep link and make a request with it. + * + * **HOW THIS RECIPE WORKS** This recipe simulates a real-world scenario where "App A" deeplinks + * into "App B". + * + * "App A" is simulated by this current module [com.example.nav3recipes.deeplink.advanced], which + * contains the [AdvancedCreateDeepLinkActivity] that allows you to create a deeplink intent and + * trigger that in either the existing Task, or in a new Task. + * + * "App B" is simulated by the module [com.example.nav3recipes.deeplink.advanced], which contains + * the MainActivity that you deeplink into. That module shows you how to build a synthetic backStack + * and how to manage the Task stack properly in order to support both Back and Up buttons. + * + * See the [README](README.md) file of current module for more info on advanced deep linking. + */ +class AdvancedCreateDeepLinkActivity: ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + EntryScreen("Sandbox - Build Your Deeplink Intent") { + val initFirstName = MENU_OPTIONS_FIRST_NAME.values.first().first() + val initLocation = MENU_OPTIONS_LOCATION.values.last().first() + val initTaskStack = MENU_OPTIONS_TASK_STACK.values.first().first() + var firstName by remember { mutableStateOf(initFirstName) } + var location by remember { mutableStateOf(initLocation) } + var taskStack by remember { mutableStateOf(initTaskStack) } + + // select first name + MenuDropDown( + menuOptions = MENU_OPTIONS_FIRST_NAME, + ) { _, selected -> + firstName = selected + } + + // select first name + MenuDropDown( + menuOptions = MENU_OPTIONS_LOCATION, + ) { _, selected -> + location = selected + } + + // select current task stack or build new task stack + MenuDropDown( + menuOptions = MENU_OPTIONS_TASK_STACK, + ) { _, selected -> + taskStack = selected + } + + // build final deeplink URL and Intent + val finalUrl = "${ADVANCED_PATH_BASE}/user/$firstName/$location" + + // display Intent info + val flagString = if (taskStack == TAG_NEW_TASK) { + "Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK" + } else "" + val intentString = """ + | Final Intent: + | data = "$finalUrl" + | action = Intent.ACTION_VIEW + | flags = $flagString + """.trimMargin() + + TextContent(intentString) + + // deeplink to target + PaddedButton("Deeplink Away!", onClick = dropUnlessResumed { + val intent = Intent().apply { + data = finalUrl.toUri() + action = Intent.ACTION_VIEW + if (taskStack == TAG_NEW_TASK) { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + } + + startActivity(intent) + }) + } + } + } +} + +private const val TAG_FIRST_NAME = "firstName" +private const val TAG_LOCATION = "location" +private const val TAG_TASK_STACK = "Task stack" +private const val TAG_CURRENT_TASK = "Use Current Task Stack" +private const val TAG_NEW_TASK = "Start New Task Stack" + +private val MENU_OPTIONS_FIRST_NAME = mapOf( + TAG_FIRST_NAME to LIST_FIRST_NAMES +) + +private val MENU_OPTIONS_LOCATION = mapOf( + TAG_LOCATION to LIST_LOCATIONS +) + +private val MENU_OPTIONS_TASK_STACK = mapOf( + TAG_TASK_STACK to listOf(TAG_CURRENT_TASK, TAG_NEW_TASK), +) +``` \ No newline at end of file diff --git a/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-basic.md b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-basic.md new file mode 100644 index 00000000..f96f642a --- /dev/null +++ b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-basic.md @@ -0,0 +1,740 @@ +# Deep Link Basic Recipe + +This recipe demonstrates how to parse a deep link URL from an Android Intent into a Navigation key. + +## How it works + +It consists of two activities - `CreateDeepLinkActivity` to construct and trigger the deeplink request, and the `MainActivity` to show how an app can handle that request. + +## Demonstrated forms of deeplink + +The `MainActivity` has several backStack keys to demonstrate different types of supported deeplinks: + +1. `HomeKey` - deeplink with an exact url (no deeplink arguments) +2. `UsersKey` - deeplink with path arguments +3. `SearchKey` - deeplink with query arguments + +See `MainActivity.deepLinkPatterns` for the actual url pattern of each. + +## Recipe structure + +This recipe consists of three main packages: + +1. `basic.deeplink` - Contains the two activities +2. `basic.deeplink.ui` - Contains the activity UI code, i.e. global string variables, deeplink URLs etc +3. `basic.deeplink.util` - Contains the classes and helper methods to parse and match the deeplinks + +[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/deeplink/basic) + +``` +package com.example.nav3recipes.deeplink.basic + +import androidx.navigation3.runtime.NavKey +import com.example.nav3recipes.deeplink.basic.ui.STRING_LITERAL_FILTER +import com.example.nav3recipes.deeplink.basic.ui.STRING_LITERAL_HOME +import com.example.nav3recipes.deeplink.basic.ui.STRING_LITERAL_SEARCH +import com.example.nav3recipes.deeplink.basic.ui.STRING_LITERAL_USERS +import kotlinx.serialization.Serializable + +internal interface NavRecipeKey: NavKey { + val name: String +} + +@Serializable +internal object HomeKey: NavRecipeKey { + override val name: String = STRING_LITERAL_HOME +} + +@Serializable +internal data class UsersKey( + val filter: String, +): NavRecipeKey { + override val name: String = STRING_LITERAL_USERS + companion object { + const val FILTER_KEY = STRING_LITERAL_FILTER + const val FILTER_OPTION_RECENTLY_ADDED = "recentlyAdded" + const val FILTER_OPTION_ALL = "all" + } +} + +@Serializable +internal data class SearchKey( + val firstName: String? = null, + val ageMin: Int? = null, + val ageMax: Int? = null, + val location: String? = null, +): NavRecipeKey { + override val name: String = STRING_LITERAL_SEARCH +} +``` + +``` +package com.example.nav3recipes.deeplink.basic + +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.core.net.toUri +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.deeplink.basic.util.DeepLinkMatcher +import com.example.nav3recipes.deeplink.basic.util.DeepLinkPattern +import com.example.nav3recipes.deeplink.basic.util.DeepLinkRequest +import com.example.nav3recipes.deeplink.basic.util.DeepLinkMatchResult +import com.example.nav3recipes.deeplink.basic.util.KeyDecoder +import com.example.nav3recipes.common.deeplink.TextContent +import com.example.nav3recipes.deeplink.basic.ui.URL_HOME_EXACT +import com.example.nav3recipes.deeplink.basic.ui.URL_SEARCH +import com.example.nav3recipes.deeplink.basic.ui.URL_USERS_WITH_FILTER +import com.example.nav3recipes.common.deeplink.EntryScreen +import com.example.nav3recipes.common.deeplink.FriendsList +import com.example.nav3recipes.common.deeplink.LIST_USERS + +/** + * Parses a target deeplink into a NavKey. There are several crucial steps involved: + * + * STEP 1.Parse supported deeplinks (URLs that can be deeplinked into) into a readily readable + * format (see [DeepLinkPattern]) + * STEP 2. Parse the requested deeplink into a readily readable, format (see [DeepLinkRequest]) + * **note** the parsed requested deeplink and parsed supported deeplinks should be cohesive with each + * other to facilitate comparison and finding a match + * STEP 3. Compare the requested deeplink target with supported deeplinks in order to find a match + * (see [DeepLinkMatchResult]). The match result's format should enable conversion from result + * to backstack key, regardless of what the conversion method may be. + * STEP 4. Associate the match results with the correct backstack key + * + * This recipes provides an example for each of the above steps by way of kotlinx.serialization. + * + * **This recipe is designed to focus on parsing an intent into a key, and therefore these additional + * deeplink considerations are not included in this scope** + * - Create synthetic backStack + * - Multi-modular setup + * - DI + * - Managing TaskStack + * - Up button ves Back Button + * + */ +class MainActivity : ComponentActivity() { + /** STEP 1. Parse supported deeplinks */ + // internal so that landing activity can link to this in the kdocs + internal val deepLinkPatterns: List> = listOf( + // "https://www.nav3recipes.com/home" + DeepLinkPattern(HomeKey.serializer(), (URL_HOME_EXACT).toUri()), + // "https://www.nav3recipes.com/users/with/{filter}" + DeepLinkPattern(UsersKey.serializer(), (URL_USERS_WITH_FILTER).toUri()), + // "https://www.nav3recipes.com/users/search?{firstName}&{age}&{location}" + DeepLinkPattern(SearchKey.serializer(), (URL_SEARCH.toUri())), + ) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // retrieve the target Uri + val uri: Uri? = intent.data + // associate the target with the correct backstack key + val key: NavKey = uri?.let { + /** STEP 2. Parse requested deeplink */ + val request = DeepLinkRequest(uri) + /** STEP 3. Compared requested with supported deeplink to find match*/ + val match = deepLinkPatterns.firstNotNullOfOrNull { pattern -> + DeepLinkMatcher(request, pattern).match() + } + /** STEP 4. If match is found, associate match to the correct key*/ + match?.let { + //leverage kotlinx.serialization's Decoder to decode + // match result into a backstack key + KeyDecoder(match.args) + .decodeSerializableValue(match.serializer) + } + } ?: HomeKey // fallback if intent.uri is null or match is not found + + /** + * Then pass starting key to backstack + */ + setContent { + val backStack: NavBackStack = rememberNavBackStack(key) + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryProvider = entryProvider { + entry { key -> + EntryScreen(key.name) { + TextContent("") + } + } + entry { key -> + EntryScreen("${key.name} : ${key.filter}") { + TextContent("") + val list = when { + key.filter.isEmpty() -> LIST_USERS + key.filter == UsersKey.FILTER_OPTION_ALL -> LIST_USERS + else -> LIST_USERS.take(5) + } + FriendsList(list) + } + } + entry { search -> + EntryScreen(search.name) { + TextContent("") + val matchingUsers = LIST_USERS.filter { user -> + (search.firstName == null || user.firstName == search.firstName) && + (search.location == null || user.location == search.location) && + (search.ageMin == null || user.age >= search.ageMin) && + (search.ageMax == null || user.age <= search.ageMax) + } + FriendsList(matchingUsers) + } + } + } + ) + } + } +} +``` + +``` +package com.example.nav3recipes.deeplink.basic + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.core.net.toUri +import androidx.lifecycle.compose.dropUnlessResumed +import com.example.nav3recipes.common.deeplink.PaddedButton +import com.example.nav3recipes.common.deeplink.EMPTY +import com.example.nav3recipes.common.deeplink.EntryScreen +import com.example.nav3recipes.common.deeplink.FIRST_NAME_JOHN +import com.example.nav3recipes.common.deeplink.FIRST_NAME_JULIE +import com.example.nav3recipes.common.deeplink.FIRST_NAME_MARY +import com.example.nav3recipes.common.deeplink.FIRST_NAME_TOM +import com.example.nav3recipes.common.deeplink.LOCATION_BC +import com.example.nav3recipes.common.deeplink.LOCATION_BR +import com.example.nav3recipes.common.deeplink.LOCATION_CA +import com.example.nav3recipes.common.deeplink.LOCATION_US +import com.example.nav3recipes.common.deeplink.MenuDropDown +import com.example.nav3recipes.common.deeplink.MenuTextInput +import com.example.nav3recipes.deeplink.basic.ui.PATH_BASE +import com.example.nav3recipes.deeplink.basic.ui.PATH_INCLUDE +import com.example.nav3recipes.deeplink.basic.ui.PATH_SEARCH +import com.example.nav3recipes.deeplink.basic.ui.STRING_LITERAL_HOME +import com.example.nav3recipes.common.deeplink.TextContent + +/** + * This activity allows the user to create a deep link and make a request with it. + * + * **HOW THIS RECIPE WORKS** it consists of two activities - [CreateDeepLinkActivity] to construct + * and trigger the deeplink request, and the [MainActivity] to show how an app can handle + * that request. + * + * **DEMONSTRATED FORMS OF DEEPLINK** The [MainActivity] has a several backStack keys to + * demonstrate different types of supported deeplinks: + * 1. [HomeKey] - deeplink with an exact url (no deeplink arguments) + * 2. [UsersKey] - deeplink with path arguments + * 3. [SearchKey] - deeplink with query arguments + * See [MainActivity.deepLinkPatterns] for the actual url pattern of each. + * + * **RECIPE STRUCTURE** This recipe consists of three main packages: + * 1. basic.deeplink - Contains the two activities + * 2. basic.deeplink.ui - Contains the activity UI code, i.e. global string variables, deeplink URLs etc + * 3. basic.deeplink.util - Contains the classes and helper methods to parse and match + * the deeplinks + * + * See [MainActivity] for how the requested deeplink is handled. + */ +class CreateDeepLinkActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + /** + * UI for deeplink sandbox + */ + EntryScreen("Sandbox - Build Your Deeplink") { + TextContent("Base url:\n${PATH_BASE}/") + var showFilterOptions by remember { mutableStateOf(false) } + val selectedPath = remember { mutableStateOf(MENU_OPTIONS_PATH[KEY_PATH]?.first()) } + + var showQueryOptions by remember { mutableStateOf(false) } + var selectedFilter by remember { mutableStateOf("") } + val selectedSearchQuery = remember { mutableStateMapOf() } + + // manage path options + MenuDropDown( + menuOptions = MENU_OPTIONS_PATH, + ) { _, selection -> + selectedPath.value = selection + when (selection) { + PATH_SEARCH -> { + showQueryOptions = true + showFilterOptions = false + } + + PATH_INCLUDE -> { + showQueryOptions = false + showFilterOptions = true + } + + else -> { + showQueryOptions = false + showFilterOptions = false + } + } + } + + // manage path filter options, reset state if menu is closed + LaunchedEffect(showFilterOptions) { + selectedFilter = if (showFilterOptions) { + MENU_OPTIONS_FILTER.values.first().first() + } else { + "" + } + } + if (showFilterOptions) { + MenuDropDown( + menuOptions = MENU_OPTIONS_FILTER, + ) { _, selected -> + selectedFilter = selected + } + } + + // manage query options, reset state if menu is closed + LaunchedEffect(showQueryOptions) { + if (showQueryOptions) { + val initEntry = MENU_OPTIONS_SEARCH.entries.first() + selectedSearchQuery[initEntry.key] = initEntry.value.first() + } else { + selectedSearchQuery.clear() + } + } + if (showQueryOptions) { + MenuTextInput( + menuLabels = MENU_LABELS_SEARCH, + ) { label, selected -> + selectedSearchQuery[label] = selected + } + MenuDropDown( + menuOptions = MENU_OPTIONS_SEARCH, + ) { label, selected -> + selectedSearchQuery[label] = selected + } + } + + // form final deeplink url + val arguments = when (selectedPath.value) { + PATH_INCLUDE -> "/${selectedFilter}" + PATH_SEARCH -> { + buildString { + selectedSearchQuery.forEach { entry -> + if (entry.value.isNotEmpty()) { + val prefix = if (isEmpty()) "?" else "&" + append("$prefix${entry.key}=${entry.value}") + } + } + } + } + + else -> "" + } + val finalUrl = "${PATH_BASE}/${selectedPath.value}$arguments" + TextContent("Final url:\n$finalUrl") + // deeplink to target + PaddedButton("Deeplink Away!", onClick = dropUnlessResumed { + val intent = Intent( + this@CreateDeepLinkActivity, + MainActivity::class.java + ) + // start activity with the url + intent.data = finalUrl.toUri() + startActivity(intent) + }) + } + } + } +} + +private const val KEY_PATH = "path" +private val MENU_OPTIONS_PATH = mapOf( + KEY_PATH to listOf( + STRING_LITERAL_HOME, + PATH_INCLUDE, + PATH_SEARCH, + ), +) + +private val MENU_OPTIONS_FILTER = mapOf( + UsersKey.FILTER_KEY to listOf(UsersKey.FILTER_OPTION_RECENTLY_ADDED, UsersKey.FILTER_OPTION_ALL), +) + +private val MENU_OPTIONS_SEARCH = mapOf( + SearchKey::firstName.name to listOf( + EMPTY, + FIRST_NAME_JOHN, + FIRST_NAME_TOM, + FIRST_NAME_MARY, + FIRST_NAME_JULIE + ), + SearchKey::location.name to listOf(EMPTY, LOCATION_CA, LOCATION_BC, LOCATION_BR, LOCATION_US) +) + +private val MENU_LABELS_SEARCH = listOf(SearchKey::ageMin.name, SearchKey::ageMax.name) + +``` + +``` +package com.example.nav3recipes.deeplink.basic.util + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.AbstractDecoder +import kotlinx.serialization.encoding.CompositeDecoder +import kotlinx.serialization.modules.EmptySerializersModule +import kotlinx.serialization.modules.SerializersModule + +/** + * Decodes the list of arguments into a a back stack key + * + * **IMPORTANT** This decoder assumes that all argument types are Primitives. + */ +@OptIn(ExperimentalSerializationApi::class) +internal class KeyDecoder( + private val arguments: Map, +) : AbstractDecoder() { + + override val serializersModule: SerializersModule = EmptySerializersModule() + private var elementIndex: Int = -1 + private var elementName: String = "" + + /** + * Decodes the index of the next element to be decoded. Index represents a position of the + * current element in the [descriptor] that can be found with [descriptor].getElementIndex. + * + * The returned index will trigger deserializer to call [decodeValue] on the argument at that + * index. + * + * The decoder continually calls this method to process the next available argument until this + * method returns [CompositeDecoder.DECODE_DONE], which indicates that there are no more + * arguments to decode. + * + * This method should sequentially return the element index for every element that has its value + * available within [arguments]. + */ + override fun decodeElementIndex(descriptor: SerialDescriptor): Int { + var currentIndex = elementIndex + while (true) { + // proceed to next element + currentIndex++ + // if we have reached the end, let decoder know there are not more arguments to decode + if (currentIndex >= descriptor.elementsCount) return CompositeDecoder.DECODE_DONE + val currentName = descriptor.getElementName(currentIndex) + // Check if bundle has argument value. If so, we tell decoder to process + // currentIndex. Otherwise, we skip this index and proceed to next index. + if (arguments.contains(currentName)) { + elementIndex = currentIndex + elementName = currentName + return elementIndex + } + } + } + + /** + * Returns argument value from the [arguments] for the argument at the index returned by + * [decodeElementIndex] + */ + override fun decodeValue(): Any { + val arg = arguments[elementName] + checkNotNull(arg) { "Unexpected null value for non-nullable argument $elementName" } + return arg + } + + override fun decodeNull(): Nothing? = null + + // we want to know if it is not null, so its !isNull + override fun decodeNotNullMark(): Boolean = arguments[elementName] != null +} +``` + +``` +package com.example.nav3recipes.deeplink.basic.util + +import android.net.Uri + +/** + * Parse the requested Uri and store it in a easily readable format + * + * @param uri the target deeplink uri to link to + */ +internal class DeepLinkRequest( + val uri: Uri +) { + /** + * A list of path segments + */ + val pathSegments: List = uri.pathSegments + + /** + * A map of query name to query value + */ + val queries = buildMap { + uri.queryParameterNames.forEach { argName -> + this[argName] = uri.getQueryParameter(argName)!! + } + } + + // TODO add parsing for other Uri components, i.e. fragments, mimeType, action +} +``` + +```` +package com.example.nav3recipes.deeplink.basic.util + +import android.net.Uri +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.SerialKind +import kotlinx.serialization.encoding.CompositeDecoder +import java.io.Serializable + +/** + * Parse a supported deeplink and stores its metadata as a easily readable format + * + * The following notes applies specifically to this particular sample implementation: + * + * The supported deeplink is expected to be built from a serializable backstack key [T] that + * supports deeplink. This means that if this deeplink contains any arguments (path or query), + * the argument name must match any of [T] member field name. + * + * One [DeepLinkPattern] should be created for each supported deeplink. This means if [T] + * supports two deeplink patterns: + * ``` + * val deeplink1 = www.nav3recipes.com/home + * val deeplink2 = www.nav3recipes.com/profile/{userId} + * ``` + * Then two [DeepLinkPattern] should be created + * ``` + * val parsedDeeplink1 = DeepLinkPattern(T.serializer(), deeplink1) + * val parsedDeeplink2 = DeepLinkPattern(T.serializer(), deeplink2) + * ``` + * + * This implementation assumes a few things: + * 1. all path arguments are required/non-nullable - partial path matches will be considered a non-match + * 2. all query arguments are optional by way of nullable/has default value + * + * @param T the backstack key type that supports the deeplinking of [uriPattern] + * @param serializer the serializer of [T] + * @param uriPattern the supported deeplink's uri pattern, i.e. "abc.com/home/{pathArg}" + */ +internal class DeepLinkPattern( + val serializer: KSerializer, + val uriPattern: Uri +) { + /** + * Help differentiate if a path segment is an argument or a static value + */ + private val regexPatternFillIn = Regex("\\{(.+?)\\}") + + // TODO make these lazy + /** + * parse the path into a list of [PathSegment] + * + * order matters here - path segments need to match in value and order when matching + * requested deeplink to supported deeplink + */ + val pathSegments: List = buildList { + uriPattern.pathSegments.forEach { segment -> + // first, check if it is a path arg + var result = regexPatternFillIn.find(segment) + if (result != null) { + // if so, extract the path arg name (the string value within the curly braces) + val argName = result.groups[1]!!.value + // from [T], read the primitive type of this argument to get the correct type parser + val elementIndex = serializer.descriptor.getElementIndex(argName) + if (elementIndex == CompositeDecoder.UNKNOWN_NAME) { + throw IllegalArgumentException( + "Path parameter '{$argName}' defined in the DeepLink $uriPattern does not exist in the Serializable class '${serializer.descriptor.serialName}'." + ) + } + + val elementDescriptor = serializer.descriptor.getElementDescriptor(elementIndex) + // finally, add the arg name and its respective type parser to the map + add(PathSegment(argName, true, getTypeParser(elementDescriptor.kind))) + } else { + // if its not a path arg, then its just a static string path segment + add(PathSegment(segment, false, getTypeParser(PrimitiveKind.STRING))) + } + } + } + + /** + * Parse supported queries into a map of queryParameterNames to [TypeParser] + * + * This will be used later on to parse a provided query value into the correct KType + */ + val queryValueParsers: Map = buildMap { + uriPattern.queryParameterNames.forEach { paramName -> + val elementIndex = serializer.descriptor.getElementIndex(paramName) + // Ignore static query parameters that are not in the Serializable class + if (elementIndex != CompositeDecoder.UNKNOWN_NAME) { + val elementDescriptor = serializer.descriptor.getElementDescriptor(elementIndex) + this[paramName] = getTypeParser(elementDescriptor.kind) + } + } + } + + /** + * Metadata about a supported path segment + */ + class PathSegment( + val stringValue: String, + val isParamArg: Boolean, + val typeParser: TypeParser + ) +} + +/** + * Parses a String into a Serializable Primitive + */ +private typealias TypeParser = (String) -> Serializable + +private fun getTypeParser(kind: SerialKind): TypeParser { + return when (kind) { + PrimitiveKind.STRING -> Any::toString + PrimitiveKind.INT -> String::toInt + PrimitiveKind.BOOLEAN -> String::toBoolean + PrimitiveKind.BYTE -> String::toByte + PrimitiveKind.CHAR -> String::toCharArray + PrimitiveKind.DOUBLE -> String::toDouble + PrimitiveKind.FLOAT -> String::toFloat + PrimitiveKind.LONG -> String::toLong + PrimitiveKind.SHORT -> String::toShort + else -> throw IllegalArgumentException( + "Unsupported argument type of SerialKind:$kind. The argument type must be a Primitive." + ) + } +} +```` + +``` +package com.example.nav3recipes.deeplink.basic.util + +import android.util.Log +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.KSerializer + +internal class DeepLinkMatcher( + val request: DeepLinkRequest, + val deepLinkPattern: DeepLinkPattern +) { + /** + * Match a [DeepLinkRequest] to a [DeepLinkPattern]. + * + * Returns a [DeepLinkMatchResult] if this matches the pattern, returns null otherwise + */ + fun match(): DeepLinkMatchResult? { + if (request.uri.scheme != deepLinkPattern.uriPattern.scheme) return null + if (!request.uri.authority.equals(deepLinkPattern.uriPattern.authority, ignoreCase = true)) return null + if (request.pathSegments.size != deepLinkPattern.pathSegments.size) return null + // exact match (url does not contain any arguments) + if (request.uri == deepLinkPattern.uriPattern) + return DeepLinkMatchResult(deepLinkPattern.serializer, mapOf()) + + val args = mutableMapOf() + // match the path + request.pathSegments + .asSequence() + // zip to compare the two objects side by side, order matters here so we + // need to make sure the compared segments are at the same position within the url + .zip(deepLinkPattern.pathSegments.asSequence()) + .forEach { it -> + // retrieve the two path segments to compare + val requestedSegment = it.first + val candidateSegment = it.second + // if the potential match expects a path arg for this segment, try to parse the + // requested segment into the expected type + if (candidateSegment.isParamArg) { + val parsedValue = try { + candidateSegment.typeParser.invoke(requestedSegment) + } catch (e: IllegalArgumentException) { + Log.e(TAG_LOG_ERROR, "Failed to parse path value:[$requestedSegment].", e) + return null + } + args[candidateSegment.stringValue] = parsedValue + } else if(requestedSegment != candidateSegment.stringValue){ + // if it's path arg is not the expected type, its not a match + return null + } + } + // match queries (if any) + request.queries.forEach { query -> + val name = query.key + // If the pattern does not define this query parameter, ignore it. + // This prevents a NullPointerException. + val queryStringParser = deepLinkPattern.queryValueParsers[name]?: return@forEach + + val queryParsedValue = try { + queryStringParser.invoke(query.value) + } catch (e: IllegalArgumentException) { + Log.e(TAG_LOG_ERROR, "Failed to parse query name:[$name] value:[${query.value}].", e) + return null + } + args[name] = queryParsedValue + } + // provide the serializer of the matching key and map of arg names to parsed arg values + return DeepLinkMatchResult(deepLinkPattern.serializer, args) + } +} + + +/** + * Created when a requested deeplink matches with a supported deeplink + * + * @param [T] the backstack key associated with the deeplink that matched with the requested deeplink + * @param serializer serializer for [T] + * @param args The map of argument name to argument value. The value is expected to have already + * been parsed from the raw url string back into its proper KType as declared in [T]. + * Includes arguments for all parts of the uri - path, query, etc. + * */ +internal data class DeepLinkMatchResult( + val serializer: KSerializer, + val args: Map +) + +const val TAG_LOG_ERROR = "Nav3RecipesDeepLink" +``` + +``` +package com.example.nav3recipes.deeplink.basic.ui + +import com.example.nav3recipes.deeplink.basic.SearchKey + +/** + * String resources + */ +internal const val STRING_LITERAL_FILTER = "filter" +internal const val STRING_LITERAL_HOME = "home" +internal const val STRING_LITERAL_USERS = "users" +internal const val STRING_LITERAL_SEARCH = "search" +internal const val STRING_LITERAL_INCLUDE = "include" +internal const val PATH_BASE = "https://www.nav3recipes.com" +internal const val PATH_INCLUDE = "$STRING_LITERAL_USERS/$STRING_LITERAL_INCLUDE" +internal const val PATH_SEARCH = "$STRING_LITERAL_USERS/$STRING_LITERAL_SEARCH" +internal const val URL_HOME_EXACT = "$PATH_BASE/$STRING_LITERAL_HOME" + +internal const val URL_USERS_WITH_FILTER = "$PATH_BASE/$PATH_INCLUDE/{$STRING_LITERAL_FILTER}" +internal val URL_SEARCH = "$PATH_BASE/$PATH_SEARCH" + + "?${SearchKey::ageMin.name}={${SearchKey::ageMin.name}}" + + "&${SearchKey::ageMax.name}={${SearchKey::ageMax.name}}" + + "&${SearchKey::firstName.name}={${SearchKey::firstName.name}}" + + "&${SearchKey::location.name}={${SearchKey::location.name}}" +``` \ No newline at end of file diff --git a/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/dialog.md b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/dialog.md new file mode 100644 index 00000000..6ef8efe5 --- /dev/null +++ b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/dialog.md @@ -0,0 +1,107 @@ +# Dialog Recipe + +This recipe demonstrates how to display a destination as a dialog. + +## How it works + +To show a destination as a dialog, you need to do two things: + +1. **Use `DialogSceneStrategy`** : Create an instance of `DialogSceneStrategy` and pass it to the `sceneStrategy` parameter of the `NavDisplay` composable. + +2. **Add metadata to the destination** : For the destination that you want to display as a dialog, add `DialogSceneStrategy.dialog()` to its metadata. This is done in the `entry` function. You can also pass a `DialogProperties` object to customize the dialog's behavior and appearance. + +In this example, `RouteB` is configured to be a dialog. When you navigate from `RouteA` to `RouteB`, `RouteB` will be displayed in a dialog window. + +The content of the dialog can be styled as needed. In this recipe, the content is clipped to have rounded corners. + +For more information, see the official documentation on [custom layouts](https://developer.android.com/guide/navigation/navigation-3/custom-layouts). +[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/dialog) + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.dialog + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.scene.DialogSceneStrategy +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.content.ContentBlue +import com.example.nav3recipes.content.ContentGreen +import com.example.nav3recipes.ui.setEdgeToEdgeConfig +import kotlinx.serialization.Serializable + +@Serializable +private data object RouteA : NavKey + +@Serializable +private data class RouteB(val id: String) : NavKey + +class DialogActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + setEdgeToEdgeConfig() + super.onCreate(savedInstanceState) + setContent { + val backStack = rememberNavBackStack(RouteA) + val dialogStrategy = remember { DialogSceneStrategy() } + + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + sceneStrategies = listOf(dialogStrategy), + entryProvider = entryProvider { + entry { + ContentGreen("Welcome to Nav3") { + Button(onClick = dropUnlessResumed { + backStack.add(RouteB("123")) + }) { + Text("Click to open dialog") + } + } + } + entry( + metadata = DialogSceneStrategy.dialog( + DialogProperties(windowTitle = "Route B dialog") + ) + ) { key -> + ContentBlue( + title = "Route id: ${key.id}", + modifier = Modifier.clip( + shape = RoundedCornerShape(16.dp) + ) + ) + } + } + ) + } + } +} +``` \ No newline at end of file diff --git a/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/material-listdetail.md b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/material-listdetail.md new file mode 100644 index 00000000..60a7fc69 --- /dev/null +++ b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/material-listdetail.md @@ -0,0 +1,141 @@ +# Material List-Detail Recipe + +This recipe demonstrates how to create an adaptive list-detail layout using the `ListDetailSceneStrategy` from the Material 3 Adaptive library. This layout automatically adjusts to show one, two, or three panes depending on the available screen width. + +## How it works + +This example has three destinations: `ConversationList`, `ConversationDetail`, and `Profile`. + +### `ListDetailSceneStrategy` + +The key to this recipe is the `rememberListDetailSceneStrategy`, which provides the logic for the adaptive layout. + +- **Pane Roles**: Each destination is assigned a role using metadata: + + - `ListDetailSceneStrategy.listPane()`: For the primary (list) content. This pane is always visible. A placeholder can be provided to be shown in the detail pane area when no detail content is selected. + - `ListDetailSceneStrategy.detailPane()`: For the secondary (detail) content. + - `ListDetailSceneStrategy.extraPane()`: For tertiary content. +- **Adaptive Layout** : The `ListDetailSceneStrategy` automatically handles the layout. On smaller screens, only one pane is shown at a time. On wider screens, it will show the list and detail panes side-by-side. On very wide screens, it can show all three panes: list, detail, and extra. + +- **Navigation** : Navigation between the panes is handled by adding and removing destinations from the back stack as usual. The `ListDetailSceneStrategy` observes the back stack and adjusts the layout accordingly. + +[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/material/listdetail) + +``` +package com.example.nav3recipes.material.listdetail + +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective +import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy +import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.content.ContentBlue +import com.example.nav3recipes.content.ContentGreen +import com.example.nav3recipes.content.ContentRed +import com.example.nav3recipes.content.ContentYellow +import com.example.nav3recipes.ui.setEdgeToEdgeConfig +import kotlinx.serialization.Serializable + +@Serializable +private object ConversationList : NavKey + +@Serializable +private data class ConversationDetail(val id: String) : NavKey + +@Serializable +private data object Profile : NavKey + +class MaterialListDetailActivity : ComponentActivity() { + + @OptIn(ExperimentalMaterial3AdaptiveApi::class) + override fun onCreate(savedInstanceState: Bundle?) { + setEdgeToEdgeConfig() + super.onCreate(savedInstanceState) + + setContent { + + val backStack = rememberNavBackStack(ConversationList) + + // Override the defaults so that there isn't a horizontal space between the panes. + // See b/418201867 + val windowAdaptiveInfo = currentWindowAdaptiveInfo() + val directive = remember(windowAdaptiveInfo) { + calculatePaneScaffoldDirective(windowAdaptiveInfo) + .copy(horizontalPartitionSpacerSize = 0.dp) + } + val listDetailStrategy = rememberListDetailSceneStrategy(directive = directive) + + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + sceneStrategies = listOf(listDetailStrategy), + entryProvider = entryProvider { + entry( + metadata = ListDetailSceneStrategy.listPane( + detailPlaceholder = { + ContentYellow("Choose a conversation from the list") + } + ) + ) { + ContentRed("Welcome to Nav3") { + Button(onClick = dropUnlessResumed { + backStack.add(ConversationDetail("ABC")) + }) { + Text("View conversation") + } + } + } + entry( + metadata = ListDetailSceneStrategy.detailPane() + ) { conversation -> + ContentBlue("Conversation ${conversation.id} ") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = dropUnlessResumed { + backStack.add(Profile) + }) { + Text("View profile") + } + } + } + } + entry( + metadata = ListDetailSceneStrategy.extraPane() + ) { + ContentGreen("Profile") + } + } + ) + } + } +} +``` \ No newline at end of file diff --git a/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/material-supportingpane.md b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/material-supportingpane.md new file mode 100644 index 00000000..97727bf7 --- /dev/null +++ b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/material-supportingpane.md @@ -0,0 +1,145 @@ +# Material Supporting Pane Recipe + +This recipe demonstrates how to create an adaptive layout with a main pane and a supporting pane using the `SupportingPaneSceneStrategy` from the Material 3 Adaptive library. This layout is useful for displaying supplementary content alongside the main content on larger screens. + +## How it works + +This example has three destinations: `MainVideo`, `RelatedVideos`, and `Profile`. + +### `SupportingPaneSceneStrategy` + +The `rememberSupportingPaneSceneStrategy` provides the logic for this adaptive layout. + +- **Pane Roles**: Each destination is assigned a role using metadata: + + - `SupportingPaneSceneStrategy.mainPane()`: For the primary content. This pane is always visible. + - `SupportingPaneSceneStrategy.supportingPane()`: For the supplementary content. This pane is shown alongside the main pane on larger screens. + - `SupportingPaneSceneStrategy.extraPane()`: For tertiary content that can be displayed alongside the supporting pane on even larger screens. +- **Adaptive Layout** : The `SupportingPaneSceneStrategy` automatically handles the layout. On smaller screens, only the main pane is shown. On larger screens, the supporting pane is shown next to the main pane. + +- **Back Navigation** : The `BackNavigationBehavior` is customized in this example to `PopUntilCurrentDestinationChange`. This means that when the user presses the back button, the supporting pane will be dismissed, revealing the main pane underneath. + +- **Navigation** : Navigation is handled by adding and removing destinations from the back stack. The `SupportingPaneSceneStrategy` observes these changes and adjusts the layout accordingly. + +[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/material/supportingpane) + +``` +package com.example.nav3recipes.material.supportingpane + +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective +import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior +import androidx.compose.material3.adaptive.navigation3.SupportingPaneSceneStrategy +import androidx.compose.material3.adaptive.navigation3.rememberSupportingPaneSceneStrategy +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.content.ContentBlue +import com.example.nav3recipes.content.ContentGreen +import com.example.nav3recipes.content.ContentRed +import com.example.nav3recipes.ui.setEdgeToEdgeConfig +import kotlinx.serialization.Serializable + +@Serializable +private object MainVideo : NavKey + +@Serializable +private data object RelatedVideos : NavKey + +@Serializable +private data object Profile : NavKey + +class MaterialSupportingPaneActivity : ComponentActivity() { + + @OptIn(ExperimentalMaterial3AdaptiveApi::class) + override fun onCreate(savedInstanceState: Bundle?) { + setEdgeToEdgeConfig() + super.onCreate(savedInstanceState) + + setContent { + + val backStack = rememberNavBackStack(MainVideo) + + // Override the defaults so that there isn't a horizontal or vertical space between the panes. + // See b/444438086 + val windowAdaptiveInfo = currentWindowAdaptiveInfo() + val directive = remember(windowAdaptiveInfo) { + calculatePaneScaffoldDirective(windowAdaptiveInfo) + .copy(horizontalPartitionSpacerSize = 0.dp, verticalPartitionSpacerSize = 0.dp) + } + + // Override the defaults so that the supporting pane can be dismissed by pressing back. + // See b/445826749 + val supportingPaneStrategy = rememberSupportingPaneSceneStrategy( + backNavigationBehavior = BackNavigationBehavior.PopUntilCurrentDestinationChange, + directive = directive + ) + + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + sceneStrategies = listOf(supportingPaneStrategy), + entryProvider = entryProvider { + entry( + metadata = SupportingPaneSceneStrategy.mainPane() + ) { + ContentRed("Video content") { + Button(onClick = dropUnlessResumed { + backStack.add(RelatedVideos) + }) { + Text("View related videos") + } + } + } + entry( + metadata = SupportingPaneSceneStrategy.supportingPane() + ) { + ContentBlue("Related videos") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = dropUnlessResumed { + backStack.add(Profile) + }) { + Text("View profile") + } + } + } + } + entry( + metadata = SupportingPaneSceneStrategy.extraPane() + ) { + ContentGreen("Profile") + } + } + ) + } + } +} +``` \ No newline at end of file diff --git a/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/modular-hilt.md b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/modular-hilt.md new file mode 100644 index 00000000..e22b0c10 --- /dev/null +++ b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/modular-hilt.md @@ -0,0 +1,283 @@ +# Modular Navigation Recipe (Hilt) + +This recipe demonstrates how to structure a multi-module application using Navigation 3 and Dagger/Hilt for dependency injection. The goal is to create a decoupled architecture where navigation is defined and implemented in separate feature modules. + +## How it works + +The application is divided into several modules: + +- **`app` module** : This is the main application module. It initializes a common `Navigator` and injects a set of `EntryProviderInstaller`s from the feature modules. It then uses these installers to build the final `entryProvider` for the `NavDisplay`. + +- **`common` module**: This module contains the core navigation logic, including: + + - A `Navigator` class that manages the back stack. + - An `EntryProviderInstaller` type, which is a function that feature modules use to contribute their navigation entries to the application's `entryProvider`. +- **Feature modules (e.g., `conversation`, `profile`)**: Each feature is split into two sub-modules: + + - **`api` module**: Defines the public API for the feature, including its navigation routes. This allows other modules to navigate to this feature without needing to know about its implementation details. + - **`impl` module** : Provides the implementation of the feature, including its composables and an `EntryProviderInstaller` that maps the feature's routes to its composables. This installer is then provided to the `app` module using Dagger/Hilt. + +This modular approach allows for a clean separation of concerns, making the codebase more scalable and maintainable. Each feature is responsible for its own navigation logic, and the `app` module only combines these pieces together. +[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/modular/hilt) + +``` +package com.example.nav3recipes.modular.hilt + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +// API +object Profile + +// IMPLEMENTATION +@Module +@InstallIn(ActivityRetainedComponent::class) +object ProfileModule { + + @IntoSet + @Provides + fun provideEntryProviderInstaller() : EntryProviderInstaller = { + entry{ + ProfileScreen() + } + } +} + +@Composable +private fun ProfileScreen() { + val profileColor = MaterialTheme.colorScheme.surfaceVariant + Column( + modifier = Modifier + .fillMaxSize() + .background(profileColor) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Profile Screen", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } +} +``` + +``` +package com.example.nav3recipes.modular.hilt + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.ui.Modifier +import androidx.navigation3.ui.NavDisplay +import androidx.navigation3.runtime.entryProvider +import com.example.nav3recipes.ui.setEdgeToEdgeConfig +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class HiltModularActivity : ComponentActivity() { + + @Inject + lateinit var navigator: Navigator + + @Inject + lateinit var entryProviderScopes: Set<@JvmSuppressWildcards EntryProviderInstaller> + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setEdgeToEdgeConfig() + setContent { + Scaffold { paddingValues -> + NavDisplay( + backStack = navigator.backStack, + modifier = Modifier.padding(paddingValues), + onBack = { navigator.goBack() }, + entryProvider = entryProvider { + entryProviderScopes.forEach { builder -> this.builder() } + } + ) + } + } + } +} +``` + +``` +package com.example.nav3recipes.modular.hilt + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Button +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.dropUnlessResumed +import com.example.nav3recipes.ui.theme.colors +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +// API +object ConversationList +data class ConversationDetail(val id: Int) { + val color: Color + get() = colors[id % colors.size] +} + +// IMPL +@Module +@InstallIn(ActivityRetainedComponent::class) +object ConversationModule { + + @IntoSet + @Provides + fun provideEntryProviderInstaller(navigator: Navigator): EntryProviderInstaller = + { + entry { + ConversationListScreen( + onConversationClicked = { conversationDetail -> + navigator.goTo(conversationDetail) + } + ) + } + entry { key -> + ConversationDetailScreen(key) { navigator.goTo(Profile) } + } + } +} + +@Composable +private fun ConversationListScreen( + onConversationClicked: (ConversationDetail) -> Unit +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + items(10) { index -> + val conversationId = index + 1 + val conversationDetail = ConversationDetail(conversationId) + val backgroundColor = conversationDetail.color + ListItem( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = dropUnlessResumed { + onConversationClicked(conversationDetail) + }), + headlineContent = { + Text( + text = "Conversation $conversationId", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface + ) + }, + colors = ListItemDefaults.colors( + containerColor = backgroundColor // Set container color directly + ) + ) + } + } +} + +@Composable +private fun ConversationDetailScreen( + conversationDetail: ConversationDetail, + onProfileClicked: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(conversationDetail.color) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Conversation Detail Screen: ${conversationDetail.id}", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = dropUnlessResumed(block = onProfileClicked)) { + Text("View Profile") + } + } +} +``` + +``` +package com.example.nav3recipes.modular.hilt + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.navigation3.runtime.EntryProviderScope +import dagger.hilt.android.scopes.ActivityRetainedScoped + + +typealias EntryProviderInstaller = EntryProviderScope.() -> Unit + +@ActivityRetainedScoped +class Navigator(startDestination: Any) { + val backStack : SnapshotStateList = mutableStateListOf(startDestination) + + fun goTo(destination: Any){ + backStack.add(destination) + } + + fun goBack(){ + backStack.removeLastOrNull() + } +} +``` + +``` +package com.example.nav3recipes.modular.hilt + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.hilt.android.scopes.ActivityRetainedScoped + +@Module +@InstallIn(ActivityRetainedComponent::class) +object AppModule { + + @Provides + @ActivityRetainedScoped + fun provideNavigator() : Navigator = Navigator(startDestination = ConversationList) +} +``` \ No newline at end of file diff --git a/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/modular-koin.md b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/modular-koin.md new file mode 100644 index 00000000..d1e7ad6b --- /dev/null +++ b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/modular-koin.md @@ -0,0 +1,287 @@ +# Modular Navigation Recipe (Koin) + +This recipe demonstrates how to structure a multi-module application using Navigation 3 and Koin for dependency injection. The goal is to create a decoupled architecture where navigation is defined and implemented in separate feature modules. It relies on the [`koin-compose-navigation3`](https://insert-koin.io/docs/reference/koin-compose/navigation3) artifact. + +## How it works + +The application is divided into several Android modules: + +- **`app` module** : This is the main application module. It `includes()` the feature modules and initializes a common `Navigator`. + +- **`common` module** : This module contains the core navigation logic used by both the application module and the feature modules. Namely, it defines a `Navigator` class that manages the back stack. + +- **Feature modules (e.g., `conversation`, `profile`)**: Each feature is split into two sub-modules: + + - **`api` module**: Defines the public API for the feature, including its navigation routes. This allows other modules to navigate to this feature without needing to know about its implementation details. + - **`impl` module** : Provides the implementation of the feature, including its composables and Koin `Module`. The Koin module uses the [`navigation`](https://insert-koin.io/docs/reference/koin-compose/navigation3/#declaring-navigation-entries) DSL to define the entry provider installers for the feature module. + +This modular approach allows for a clean separation of concerns, making the codebase more scalable and maintainable. Each feature is responsible for its own navigation logic, and the `app` module only combines these pieces together. +[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/modular/koin) + +``` +package com.example.nav3recipes.modular.koin + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.koin.androidx.scope.dsl.activityRetainedScope +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.dsl.module +import org.koin.dsl.navigation3.navigation + +// API +object Profile + +// IMPL +@OptIn(KoinExperimentalAPI::class) +val profileModule = module { + activityRetainedScope { + navigation { ProfileScreen() } + } +} + +@Composable +private fun ProfileScreen() { + val profileColor = MaterialTheme.colorScheme.surfaceVariant + Column( + modifier = Modifier + .fillMaxSize() + .background(profileColor) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Profile Screen", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } +} +``` + +``` +package com.example.nav3recipes.modular.koin + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Button +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.dropUnlessResumed +import com.example.nav3recipes.ui.theme.colors +import org.koin.androidx.scope.dsl.activityRetainedScope +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.dsl.module +import org.koin.dsl.navigation3.navigation + +// API +object ConversationList +data class ConversationDetail(val id: Int) { + val color: Color + get() = colors[id % colors.size] +} + +// IMPL +@OptIn(KoinExperimentalAPI::class) +val conversationModule = module { + activityRetainedScope { + navigation { + ConversationListScreen( + onConversationClicked = { conversationDetail -> + get().goTo(conversationDetail) + } + ) + } + + navigation { key -> + ConversationDetailScreen(key) { + get().goTo(Profile) + } + } + } +} + +@Composable +private fun ConversationListScreen( + onConversationClicked: (ConversationDetail) -> Unit +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + items(10) { index -> + val conversationId = index + 1 + val conversationDetail = ConversationDetail(conversationId) + val backgroundColor = conversationDetail.color + ListItem( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = dropUnlessResumed { + onConversationClicked(conversationDetail) + }), + headlineContent = { + Text( + text = "Conversation $conversationId", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface + ) + }, + colors = ListItemDefaults.colors( + containerColor = backgroundColor // Set container color directly + ) + ) + } + } +} + +@Composable +private fun ConversationDetailScreen( + conversationDetail: ConversationDetail, + onProfileClicked: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(conversationDetail.color) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Conversation Detail Screen: ${conversationDetail.id}", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = dropUnlessResumed(block = onProfileClicked)) { + Text("View Profile") + } + } +} +``` + +``` +package com.example.nav3recipes.modular.koin + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList + +class Navigator(startDestination: Any) { + val backStack : SnapshotStateList = mutableStateListOf(startDestination) + + fun goTo(destination: Any){ + backStack.add(destination) + } + + fun goBack(){ + backStack.removeLastOrNull() + } +} +``` + +``` +package com.example.nav3recipes.modular.koin + +import org.koin.androidx.scope.dsl.activityRetainedScope +import org.koin.dsl.module + +val appModule = module { + includes(profileModule,conversationModule) + + activityRetainedScope { + scoped { + Navigator(startDestination = ConversationList) + } + } +} +``` + +``` +package com.example.nav3recipes.modular.koin + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.ui.Modifier +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.ui.setEdgeToEdgeConfig +import org.koin.android.ext.android.inject +import org.koin.android.scope.AndroidScopeComponent +import org.koin.androidx.compose.navigation3.getEntryProvider +import org.koin.androidx.scope.activityRetainedScope +import org.koin.core.Koin +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.core.component.KoinComponent +import org.koin.core.scope.Scope +import org.koin.dsl.koinApplication + +/** + * This recipe demonstrates how to use a modular approach with Navigation 3, + * where different parts of the application are defined in separate modules and injected + * into the main app using Koin. + * + * Features (Conversation and Profile) are split into two modules: + * - api: defines the public facing routes for this feature + * - impl: defines the entryProviders for this feature, these are injected into the app's main activity + * The common module defines: + * - a common navigator class that exposes a back stack and methods to modify that back stack + * - a type that should be used by feature modules to inject entryProviders into the app's main activity + * The app module creates the navigator by supplying a start destination and provides this navigator + * to the rest of the app module (i.e. MainActivity) and the feature modules. + */ +@OptIn(KoinExperimentalAPI::class) +class KoinModularActivity : ComponentActivity(), AndroidScopeComponent, KoinComponent { + // Local Koin Context Instance + companion object { + private val localKoin = koinApplication { + modules(appModule) + }.koin + } + // Override default Koin context to use the local one + override fun getKoin(): Koin = localKoin + override val scope : Scope by activityRetainedScope() + val navigator: Navigator by inject() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setEdgeToEdgeConfig() + setContent { + Scaffold { paddingValues -> + NavDisplay( + backStack = navigator.backStack, + modifier = Modifier.padding(paddingValues), + onBack = { navigator.goBack() }, + entryProvider = getEntryProvider() + ) + } + } + } + +} +``` \ No newline at end of file diff --git a/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/multiple-backstacks.md b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/multiple-backstacks.md new file mode 100644 index 00000000..b7f7ebba --- /dev/null +++ b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/multiple-backstacks.md @@ -0,0 +1,436 @@ +# Multiple back stacks recipe + +This recipe demonstrates how to create multiple back stacks. + +The app has three top level routes: `RouteA`, `RouteB` and `RouteC`. These routes have sub routes `RouteA1`, `RouteB1` and `RouteC1` respectively. The content for the sub routes is a counter that can be used to verify state retention through configuration changes and process death. + +The app's navigation state is held in the `NavigationState` class. The state itself is created using `rememberNavigationState`. + +Navigation events are handled by the `Navigator`. It updates the navigation state. + +The navigation state is converted into `NavEntry`s with `NavigationState.toDecoratedEntries`. These entries are then displayed by `NavDisplay`. + +Key behaviors: + +- This app follows the "exit through home" pattern where the user always exits through the starting back stack. This means that `RouteA`'s entries are *always* in the list of entries. +- Navigating to a top level route that is not the starting route *replaces* the other entries. For example, navigating A-\>B-\>C would result in entries for A+C, B's entries are removed. + +Important implementation details: + +- Each top level route has its own `SaveableStateHolderNavEntryDecorator`. This is the object responsible for managing the state for the entries in its back stack. + +[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/multiplestacks) + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.multiplestacks + +import androidx.navigation3.runtime.NavKey + +/** + * Handles navigation events (forward and back) by updating the navigation state. + */ +class Navigator(val state: NavigationState){ + fun navigate(route: NavKey){ + if (route in state.backStacks.keys){ + // This is a top level route, just switch to it + state.topLevelRoute = route + } else { + state.backStacks[state.topLevelRoute]?.add(route) + } + } + + fun goBack(){ + val currentStack = state.backStacks[state.topLevelRoute] ?: + error("Stack for ${state.topLevelRoute} not found") + val currentRoute = currentStack.last() + + // If we're at the base of the current route, go back to the start route stack. + if (currentRoute == state.topLevelRoute){ + state.topLevelRoute = state.startRoute + } else { + currentStack.removeLastOrNull() + } + } +} +``` + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.multiplestacks + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSerializable +import androidx.compose.runtime.setValue +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberDecoratedNavEntries +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.runtime.serialization.NavKeySerializer +import androidx.savedstate.compose.serialization.serializers.MutableStateSerializer + +/** + * Create a navigation state that persists config changes and process death. + * + * @param startRoute - The top level route to start on. This should also be in `topLevelRoutes`. + * @param topLevelRoutes - The top level routes in the app. + */ +@Composable +fun rememberNavigationState( + startRoute: NavKey, + topLevelRoutes: Set +): NavigationState { + + val topLevelRoute = rememberSerializable( + startRoute, topLevelRoutes, + serializer = MutableStateSerializer(NavKeySerializer()) + ) { + mutableStateOf(startRoute) + } + + // Create a back stack for each top level route. + val backStacks = topLevelRoutes.associateWith { key -> rememberNavBackStack(key) } + + return remember(startRoute, topLevelRoutes) { + NavigationState( + startRoute = startRoute, + topLevelRoute = topLevelRoute, + backStacks = backStacks + ) + } +} + +/** + * State holder for navigation state. This class does not modify its own state. It is designed + * to be modified using the `Navigator` class. + * + * @param startRoute - the start route. The user will exit the app through this route. + * @param topLevelRoute - the state object that backs the top level route. + * @param backStacks - the back stacks for each top level route. + */ +class NavigationState( + val startRoute: NavKey, + topLevelRoute: MutableState, + val backStacks: Map> +) { + + /** + * The top level route. + */ + var topLevelRoute: NavKey by topLevelRoute + + /** + * Convert the navigation state into `NavEntry`s that have been decorated with a + * `SaveableStateHolder`. + * + * @param entryProvider - the entry provider used to convert the keys in the + * back stacks to `NavEntry`s. + */ + @Composable + fun toDecoratedEntries( + entryProvider: (NavKey) -> NavEntry + ): List> { + + // For each back stack, create a `SaveableStateHolder` decorator and use it to decorate + // the entries from that stack. When backStacks changes, `rememberDecoratedNavEntries` will + // be recomposed and a new list of decorated entries is returned. + val decoratedEntries = backStacks.mapValues { (_, stack) -> + val decorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + ) + rememberDecoratedNavEntries( + backStack = stack, + entryDecorators = decorators, + entryProvider = entryProvider + ) + } + + // Only return the entries for the stacks that are currently in use. + return getTopLevelRoutesInUse() + .flatMap { decoratedEntries[it] ?: emptyList() } + } + + /** + * Get the top level routes that are currently in use. The start route is always the first route + * in the list. This means the user will always exit the app through the starting route + * ("exit through home" pattern). The list will contain a maximum of one other route. This is a + * design decision. In your app, you may wish to allow more than two top level routes to be + * active. + * + * Note that even if a top level route is not in use its state is still retained. + * + * @return the current top level routes that are in use. + */ + private fun getTopLevelRoutesInUse() : List = + if (topLevelRoute == startRoute) { + listOf(startRoute) + } else { + listOf(startRoute, topLevelRoute) + } +} +``` + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.multiplestacks + +import android.annotation.SuppressLint +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Camera +import androidx.compose.material.icons.filled.Face +import androidx.compose.material.icons.filled.Home +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.ui.setEdgeToEdgeConfig +import kotlinx.serialization.Serializable + + +@Serializable +data object RouteA : NavKey + +@Serializable +data object RouteA1 : NavKey + +@Serializable +data object RouteB : NavKey + +@Serializable +data object RouteB1 : NavKey + +@Serializable +data object RouteC : NavKey + +@Serializable +data object RouteC1 : NavKey + +private val TOP_LEVEL_ROUTES = mapOf( + RouteA to NavBarItem(icon = Icons.Default.Home, description = "Route A"), + RouteB to NavBarItem(icon = Icons.Default.Face, description = "Route B"), + RouteC to NavBarItem(icon = Icons.Default.Camera, description = "Route C"), +) + +data class NavBarItem( + val icon: ImageVector, + val description: String +) + +class MultipleStacksActivity : ComponentActivity() { + @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") + override fun onCreate(savedInstanceState: Bundle?) { + setEdgeToEdgeConfig() + super.onCreate(savedInstanceState) + setContent { + val navigationState = rememberNavigationState( + startRoute = RouteA, + topLevelRoutes = TOP_LEVEL_ROUTES.keys + ) + + val navigator = remember { Navigator(navigationState) } + + val entryProvider = entryProvider { + featureASection(onSubRouteClick = { navigator.navigate(RouteA1) }) + featureBSection(onSubRouteClick = { navigator.navigate(RouteB1) }) + featureCSection(onSubRouteClick = { navigator.navigate(RouteC1) }) + } + + Scaffold(bottomBar = { + NavigationBar { + TOP_LEVEL_ROUTES.forEach { (key, value) -> + val isSelected = key == navigationState.topLevelRoute + NavigationBarItem( + selected = isSelected, + onClick = { navigator.navigate(key) }, + icon = { + Icon( + imageVector = value.icon, + contentDescription = value.description + ) + }, + label = { Text(value.description) } + ) + } + } + }) { + NavDisplay( + entries = navigationState.toDecoratedEntries(entryProvider), + onBack = { navigator.goBack() } + ) + } + } + } +} +``` + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.multiplestacks + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.example.nav3recipes.content.ContentGreen +import com.example.nav3recipes.content.ContentMauve +import com.example.nav3recipes.content.ContentOrange +import com.example.nav3recipes.content.ContentPink +import com.example.nav3recipes.content.ContentPurple +import com.example.nav3recipes.content.ContentRed + +fun EntryProviderScope.featureASection( + onSubRouteClick: () -> Unit, +) { + entry { + ContentRed("Route A") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = dropUnlessResumed(block = onSubRouteClick)) { + Text("Go to A1") + } + } + } + } + entry { + ContentPink("Route A1") { + var count by rememberSaveable { + mutableIntStateOf(0) + } + + Button(onClick = dropUnlessResumed { count++ }) { + Text("Value: $count") + } + } + } +} + +fun EntryProviderScope.featureBSection( + onSubRouteClick: () -> Unit, +) { + entry { + ContentGreen("Route B") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = dropUnlessResumed(block = onSubRouteClick)) { + Text("Go to B1") + } + } + } + } + entry { + ContentPurple("Route B1") { + var count by rememberSaveable { + mutableIntStateOf(0) + } + Button(onClick = dropUnlessResumed { count++ }) { + Text("Value: $count") + } + } + } +} + +fun EntryProviderScope.featureCSection( + onSubRouteClick: () -> Unit, +) { + entry { + ContentMauve("Route C") { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = dropUnlessResumed(block = onSubRouteClick)) { + Text("Go to C1") + } + } + } + } + entry { + ContentOrange("Route C1") { + var count by rememberSaveable { + mutableIntStateOf(0) + } + + Button(onClick = dropUnlessResumed { count++ }) { + Text("Value: $count") + } + } + } +} +``` \ No newline at end of file diff --git a/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/passingarguments.md b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/passingarguments.md new file mode 100644 index 00000000..292bee32 --- /dev/null +++ b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/passingarguments.md @@ -0,0 +1,376 @@ +# Passing Arguments to ViewModels (Hilt) + +This recipe demonstrates how to pass navigation arguments (keys) to a `ViewModel` using Hilt for dependency injection. + +## How it works + +This example uses Dagger/Hilt's assisted injection feature: + +1. The `ViewModel` is annotated with `@HiltViewModel` and its constructor uses `@AssistedInject` to receive the navigation key (which is annotated with `@Assisted`). +2. An `@AssistedFactory` interface is defined to create the `ViewModel`. +3. The `hiltViewModel` composable function is used to obtain the `ViewModel` instance. A `creationCallback` is provided to pass the navigation key to the factory, making it available to the `ViewModel`. + +**Note** : The `rememberViewModelStoreNavEntryDecorator` is added to the `NavDisplay`'s `entryDecorators`. This ensures that `ViewModel`s are correctly scoped to their corresponding `NavEntry`, so that a new `ViewModel` instance is created for each unique navigation key. +[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/passingarguments/viewmodels/hilt) + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.passingarguments.viewmodels.hilt + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.content.ContentBlue +import com.example.nav3recipes.content.ContentGreen +import com.example.nav3recipes.passingarguments.viewmodels.basic.RouteB +import com.example.nav3recipes.ui.setEdgeToEdgeConfig +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.HiltViewModel + +data object RouteA +data class RouteB(val id: String) + +@AndroidEntryPoint +class HiltViewModelsActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + setEdgeToEdgeConfig() + super.onCreate(savedInstanceState) + setContent { + val backStack = remember { mutableStateListOf(RouteA) } + + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + + // In order to add the `ViewModelStoreNavEntryDecorator` (see comment below for why) + // we also need to add the default `NavEntryDecorator`s as well. These provide + // extra information to the entry's content to enable it to display correctly + // and save its state. + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator() + ), + entryProvider = entryProvider { + entry { + ContentGreen("Welcome to Nav3") { + LazyColumn { + items(10) { i -> + Button(onClick = dropUnlessResumed { + backStack.add(RouteB("$i")) + }) { + Text("$i") + } + } + } + } + } + entry { key -> + val viewModel = hiltViewModel( + // Note: We need a new ViewModel for every new RouteB instance. Usually + // we would need to supply a `key` String that is unique to the + // instance, however, the ViewModelStoreNavEntryDecorator (supplied + // above) does this for us, using `NavEntry.contentKey` to uniquely + // identify the viewModel. + // + // tl;dr: Make sure you use rememberViewModelStoreNavEntryDecorator() + // if you want a new ViewModel for each new navigation key instance. + creationCallback = { factory -> + factory.create(key) + } + ) + ScreenB(viewModel = viewModel) + } + } + ) + } + } +} + +@Composable +fun ScreenB(viewModel: RouteBViewModel) { + ContentBlue("Route id: ${viewModel.navKey.id} ") +} + +@HiltViewModel(assistedFactory = RouteBViewModel.Factory::class) +class RouteBViewModel @AssistedInject constructor( + @Assisted val navKey: RouteB +) : ViewModel() { + + @AssistedFactory + interface Factory { + fun create(navKey: RouteB): RouteBViewModel + } +} +``` + +# Passing Arguments to ViewModels (Basic) + +This recipe demonstrates how to pass navigation arguments (keys) to a `ViewModel` using a custom `ViewModelProvider.Factory`. + +## How it works + +1. A custom `ViewModelProvider.Factory` is created that takes the navigation key as a constructor parameter. +2. Inside the `entry` composable, `viewModel(factory = ...)` is used to create the `ViewModel` instance, passing the current navigation key to the factory. This makes the navigation key available to the `ViewModel`. + +**Note** : The `rememberViewModelStoreNavEntryDecorator` is added to the `NavDisplay`'s `entryDecorators`. This ensures that `ViewModel`s are correctly scoped to their corresponding `NavEntry`, so that a new `ViewModel` instance is created for each unique navigation key. +[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/passingarguments/viewmodels/basic) + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.passingarguments.viewmodels.basic + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.content.ContentBlue +import com.example.nav3recipes.content.ContentGreen +import com.example.nav3recipes.ui.setEdgeToEdgeConfig + +data object RouteA + +data class RouteB(val id: String) + +class BasicViewModelsActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + setEdgeToEdgeConfig() + super.onCreate(savedInstanceState) + setContent { + val backStack = remember { mutableStateListOf(RouteA) } + + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + // In order to add the `ViewModelStoreNavEntryDecorator` (see comment below for why) + // we also need to add the default `NavEntryDecorator`s as well. These provide + // extra information to the entry's content to enable it to display correctly + // and save its state. + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator() + ), + entryProvider = entryProvider { + entry { + ContentGreen("Welcome to Nav3") { + LazyColumn { + items(10) { i -> + Button(onClick = dropUnlessResumed { + backStack.add(RouteB("$i")) + }) { + Text("$i") + } + } + } + } + } + entry { key -> + // Note: We need a new ViewModel for every new RouteB instance. Usually + // we would need to supply a `key` String that is unique to the + // instance, however, the ViewModelStoreNavEntryDecorator (supplied + // above) does this for us, using `NavEntry.contentKey` to uniquely + // identify the viewModel. + // + // tl;dr: Make sure you use rememberViewModelStoreNavEntryDecorator() + // if you want a new ViewModel for each new navigation key instance. + ScreenB(viewModel = viewModel(factory = RouteBViewModel.Factory(key))) + } + } + ) + } + } +} + +@Composable +fun ScreenB(viewModel: RouteBViewModel = viewModel()) { + ContentBlue("Route id: ${viewModel.key.id} ") +} + +class RouteBViewModel( + val key: RouteB +) : ViewModel() { + class Factory( + private val key: RouteB, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return RouteBViewModel(key) as T + } + } +} +``` + +# Passing Arguments to ViewModels (Koin) + +This recipe demonstrates how to pass navigation arguments (keys) to a `ViewModel` using Koin for dependency injection. + +## How it works + +1. A Koin module is defined that provides the `ViewModel`. +2. The `koinViewModel` composable function is used to get the `ViewModel` instance. +3. The navigation key is passed to the `ViewModel`'s constructor using `parametersOf(key)`. This makes the navigation key available to the `ViewModel`. + +**Note** : The `rememberViewModelStoreNavEntryDecorator` is added to the `NavDisplay`'s `entryDecorators`. This ensures that `ViewModel`s are correctly scoped to their corresponding `NavEntry`, so that a new `ViewModel` instance is created for each unique navigation key. +[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/passingarguments/viewmodels/koin) + +``` +package com.example.nav3recipes.passingarguments.viewmodels.koin + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.content.ContentBlue +import com.example.nav3recipes.content.ContentGreen +import com.example.nav3recipes.ui.setEdgeToEdgeConfig +import org.koin.android.ext.koin.androidContext +import org.koin.compose.KoinApplication +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.context.GlobalContext +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.core.module.dsl.viewModelOf +import org.koin.core.parameter.parametersOf +import org.koin.dsl.koinConfiguration +import org.koin.dsl.module +import org.koin.mp.KoinPlatform + +data object RouteA +data class RouteB(val id: String) + +class KoinViewModelsActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + + setEdgeToEdgeConfig() + super.onCreate(savedInstanceState) + setContent { + val backStack = remember { mutableStateListOf(RouteA) } + + // Koin Compose Entry point + KoinApplication( + configuration = koinConfiguration { + modules(appModule) + } + ) { + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + + // In order to add the `ViewModelStoreNavEntryDecorator` (see comment below for why) + // we also need to add the default `NavEntryDecorator`s as well. These provide + // extra information to the entry's content to enable it to display correctly + // and save its state. + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator() + ), + entryProvider = entryProvider { + entry { + ContentGreen("Welcome to Nav3") { + LazyColumn { + items(10) { i -> + Button(onClick = dropUnlessResumed { + backStack.add(RouteB("$i")) + }) { + Text("$i") + } + } + } + } + } + entry { key -> + val viewModel = koinViewModel { + parametersOf(key) + } + ScreenB(viewModel = viewModel) + } + } + ) + } + } + } +} + +// Local Koin Module +private val appModule = module { + viewModelOf(::RouteBViewModel) +} + +@Composable +fun ScreenB(viewModel: RouteBViewModel) { + ContentBlue("Route id: ${viewModel.navKey.id} ") +} + +class RouteBViewModel(val navKey: RouteB) : ViewModel() +``` \ No newline at end of file diff --git a/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/results-event.md b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/results-event.md new file mode 100644 index 00000000..068d0e5e --- /dev/null +++ b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/results-event.md @@ -0,0 +1,400 @@ +# Returning a Result (Event-Based) + +This recipe demonstrates how to return a result from one screen to a previous screen using an event-based approach. + +## How it works + +This example uses a `ResultEventBus` to facilitate communication between the screens. + +1. **`ResultEventBus`**: A simple event bus is created and made available to the composables. +2. **Sending the result** : The screen that produces the result calls `resultBus.sendResult(person)` to send the data back as a one-time event. +3. **Receiving the result** : The screen that needs the result uses a `ResultEffect` composable to listen for results of a specific type. When a result is received, the effect's lambda is triggered. + +This approach is useful for results that are transient and should be handled as one-time events. +[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/results/event) + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.results.common + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel + +class HomeViewModel : ViewModel() { + var person by mutableStateOf(null) +} +``` + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.results.common + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +@Serializable +data object Home : NavKey + +@Serializable +class PersonDetailsForm : NavKeyhttps://github.com/android/nav3-recipes/blob/d2a2288a393dfa373e02b04c48c483cd9add9dbf/app/src/main/java/com/example/nav3recipes/results/common/NavKeys.kt +``` + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.results.common + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class Person(val name: String, val favoriteColor: String) : Parcelablehttps://github.com/android/nav3-recipes/blob/d2a2288a393dfa373e02b04c48c483cd9add9dbf/app/src/main/java/com/example/nav3recipes/results/common/Person.kt +``` + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.results.common + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.dropUnlessResumed +import com.example.nav3recipes.content.ContentBlue +import com.example.nav3recipes.content.ContentGreen + +@Composable +fun HomeScreen( + person: Person?, + onNext: () -> Unit +) { + ContentBlue("Hello ${person?.name ?: "unknown person"}") { + + if (person != null) { + Text("Your favorite color is ${person.favoriteColor}") + } + + Spacer(Modifier.height(16.dp)) + Button(onClick = dropUnlessResumed(block = onNext)) { + Text("Tell us about yourself") + } + } +} + +@Composable +fun PersonDetailsScreen( + onSubmit: (Person) -> Unit +) { + ContentGreen("About you") { + + val nameTextState = rememberTextFieldState() + OutlinedTextField( + state = nameTextState, + label = { Text("Please enter your name") } + ) + + val favoriteColorTextState = rememberTextFieldState() + OutlinedTextField( + state = favoriteColorTextState, + label = { Text("Please enter your favorite color") } + ) + + Button( + onClick = dropUnlessResumed { + val person = Person( + name = nameTextState.text.toString(), + favoriteColor = favoriteColorTextState.text.toString() + ) + onSubmit(person) + }, + enabled = nameTextState.text.isNotBlank() && + favoriteColorTextState.text.isNotBlank() + ) { + Text("Submit") + } + } +} +``` + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.results.event + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect + +/** + * An Effect to provide a result even between different screens + * + * The trailing lambda provides the result from a flow of results. + * + * @param resultEventBus the ResultEventBus to retrieve the result from. The default value + * is read from the `LocalResultEventBus` composition local. + * @param resultKey the key that should be associated with this effect + * @param onResult the callback to invoke when a result is received + */ +@Composable +inline fun ResultEffect( + resultEventBus: ResultEventBus = LocalResultEventBus.current, + resultKey: String = T::class.toString(), + crossinline onResult: suspend (T) -> Unit +) { + LaunchedEffect(resultKey, resultEventBus.channelMap[resultKey]) { + resultEventBus.getResultFlow(resultKey)?.collect { result -> + onResult.invoke(result as T) + } + } +} +``` + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.results.event + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.results.common.Home +import com.example.nav3recipes.results.common.HomeScreen +import com.example.nav3recipes.results.common.HomeViewModel +import com.example.nav3recipes.results.common.Person +import com.example.nav3recipes.results.common.PersonDetailsForm +import com.example.nav3recipes.results.common.PersonDetailsScreen + +class ResultEventActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + val resultBus = remember { ResultEventBus() } + + Scaffold { paddingValues -> + + val backStack = rememberNavBackStack(Home) + + NavDisplay( + backStack = backStack, + modifier = Modifier.padding(paddingValues), + onBack = { backStack.removeLastOrNull() }, + entryProvider = entryProvider { + entry { + val viewModel = viewModel(key = Home.toString()) + ResultEffect(resultBus) { person -> + viewModel.person = person + } + + val person = viewModel.person + HomeScreen( + person = person, + onNext = { backStack.add(PersonDetailsForm()) } + ) + } + entry { + PersonDetailsScreen( + onSubmit = { person -> + resultBus.sendResult(result = person) + backStack.removeLastOrNull() + } + ) + } + } + ) + } + } + } +} +``` + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.results.event + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.ProvidedValue +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.mutableStateMapOf +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.BUFFERED +import kotlinx.coroutines.flow.receiveAsFlow + +/** + * Local for receiving results in a [ResultEventBus] + */ +object LocalResultEventBus { + private val LocalResultEventBus: ProvidableCompositionLocal = + compositionLocalOf { null } + + /** + * The current [ResultEventBus] + */ + val current: ResultEventBus + @Composable + get() = LocalResultEventBus.current ?: error("No ResultEventBus has been provided") + + /** + * Provides a [ResultEventBus] to the composition + */ + infix fun provides( + bus: ResultEventBus + ): ProvidedValue { + return LocalResultEventBus.provides(bus) + } +} +/** + * An EventBus for passing results between multiple sets of screens. + * + * It provides a solution for event based results. + */ +class ResultEventBus { + /** + * Map from the result key to a channel of results. + */ + val channelMap = mutableStateMapOf>() + + /** + * Provides a flow for the given resultKey. + */ + inline fun getResultFlow(resultKey: String = T::class.toString()) = + channelMap[resultKey]?.receiveAsFlow() + + /** + * Sends a result into the channel associated with the given resultKey. + */ + inline fun sendResult(resultKey: String = T::class.toString(), result: T) { + if (!channelMap.contains(resultKey)) { + channelMap[resultKey] = Channel(capacity = BUFFERED, onBufferOverflow = BufferOverflow.SUSPEND) + } + channelMap[resultKey]?.trySend(result) + } + + /** + * Removes all results associated with the given key from the store. + */ + inline fun removeResult(resultKey: String = T::class.toString()) { + channelMap.remove(resultKey) + } +} +``` \ No newline at end of file diff --git a/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/results-state.md b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/results-state.md new file mode 100644 index 00000000..1f9323ef --- /dev/null +++ b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/results-state.md @@ -0,0 +1,369 @@ +# Returning a Result (State-Based) + +This recipe demonstrates how to return a result from one screen to a previous screen using a state-based approach. + +## How it works + +This example uses a `ResultStore` to manage the result as state. + +1. **`ResultStore`** : A `ResultStore` is created and made available to the composables. This store holds the results. +2. **Setting the result** : The screen that produces the result calls `resultStore.setResult(person)` to save the data in the store. +3. **Observing the result** : The screen that needs the result calls `resultStore.getResultState()` to get a `State` object representing the result. The UI then observes this state and recomposes whenever the result changes. + +This approach is suitable when the result should be treated as persistent state that survives recomposition and configuration changes. +[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/results/state) + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.results.common + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel + +class HomeViewModel : ViewModel() { + var person by mutableStateOf(null) +} +``` + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.results.common + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +@Serializable +data object Home : NavKey + +@Serializable +class PersonDetailsForm : NavKeyhttps://github.com/android/nav3-recipes/blob/d2a2288a393dfa373e02b04c48c483cd9add9dbf/app/src/main/java/com/example/nav3recipes/results/common/NavKeys.kt +``` + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.results.common + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class Person(val name: String, val favoriteColor: String) : Parcelablehttps://github.com/android/nav3-recipes/blob/d2a2288a393dfa373e02b04c48c483cd9add9dbf/app/src/main/java/com/example/nav3recipes/results/common/Person.kt +``` + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.results.common + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.dropUnlessResumed +import com.example.nav3recipes.content.ContentBlue +import com.example.nav3recipes.content.ContentGreen + +@Composable +fun HomeScreen( + person: Person?, + onNext: () -> Unit +) { + ContentBlue("Hello ${person?.name ?: "unknown person"}") { + + if (person != null) { + Text("Your favorite color is ${person.favoriteColor}") + } + + Spacer(Modifier.height(16.dp)) + Button(onClick = dropUnlessResumed(block = onNext)) { + Text("Tell us about yourself") + } + } +} + +@Composable +fun PersonDetailsScreen( + onSubmit: (Person) -> Unit +) { + ContentGreen("About you") { + + val nameTextState = rememberTextFieldState() + OutlinedTextField( + state = nameTextState, + label = { Text("Please enter your name") } + ) + + val favoriteColorTextState = rememberTextFieldState() + OutlinedTextField( + state = favoriteColorTextState, + label = { Text("Please enter your favorite color") } + ) + + Button( + onClick = dropUnlessResumed { + val person = Person( + name = nameTextState.text.toString(), + favoriteColor = favoriteColorTextState.text.toString() + ) + onSubmit(person) + }, + enabled = nameTextState.text.isNotBlank() && + favoriteColorTextState.text.isNotBlank() + ) { + Text("Submit") + } + } +} +``` + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.results.state + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.ui.Modifier +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.results.common.Home +import com.example.nav3recipes.results.common.HomeScreen +import com.example.nav3recipes.results.common.Person +import com.example.nav3recipes.results.common.PersonDetailsForm +import com.example.nav3recipes.results.common.PersonDetailsScreen + +class ResultStateActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + val resultStore = rememberResultStore() + + Scaffold { paddingValues -> + val backStack = rememberNavBackStack(Home) + + NavDisplay( + backStack = backStack, + modifier = Modifier.padding(paddingValues), + onBack = { backStack.removeLastOrNull() }, + entryProvider = entryProvider { + entry { + val person = resultStore.getResultState() + HomeScreen( + person = person, + onNext = { backStack.add(PersonDetailsForm()) } + ) + } + entry { + PersonDetailsScreen( + onSubmit = { person -> + resultStore.setResult(result = person) + backStack.removeLastOrNull() + } + ) + } + } + ) + } + } + } +} +``` + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.results.state + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.ProvidedValue +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.mapSaver +import androidx.compose.runtime.saveable.rememberSaveable + +/** + * Local for storing results in a [ResultStore] + */ +object LocalResultStore { + private val LocalResultStore: ProvidableCompositionLocal = + compositionLocalOf { null } + + /** + * The current [ResultStore] + */ + val current: ResultStore + @Composable + get() = LocalResultStore.current ?: error("No ResultStore has been provided") + + /** + * Provides a [ResultStore] to the composition + */ + infix fun provides( + store: ResultStore + ): ProvidedValue { + return LocalResultStore.provides(store) + } +} + +/** + * Provides a [ResultStore] that will be remembered across configuration changes. + */ +@Composable +fun rememberResultStore(): ResultStore { + return rememberSaveable(saver = ResultStoreSaver()) { + ResultStore() + } +} + +/** + * A store for passing results between multiple sets of screens. + * + * It provides a solution for state based results. + */ +class ResultStore { + + /** + * Map from the result key to a mutable state of the result. + */ + val resultStateMap = mutableStateMapOf>() + + /** + * Retrieves the current result of the given resultKey. + */ + inline fun getResultState(resultKey: String = T::class.toString()) = + resultStateMap[resultKey]?.value as T + + /** + * Sets the result for the given resultKey. + */ + inline fun setResult(resultKey: String = T::class.toString(), result: T) { + resultStateMap[resultKey] = mutableStateOf(result) + } + + /** + * Removes all results associated with the given key from the store. + */ + inline fun removeResult(resultKey: String = T::class.toString()) { + resultStateMap.remove(resultKey) + } +} + +/** Saver to save and restore the ResultStore across config change and process death. */ +private fun ResultStoreSaver(): Saver = + mapSaver( + save = { resultStore -> + resultStore.resultStateMap.mapValues { it.value.value } + }, + restore = { restoredMap -> + ResultStore().apply { + restoredMap.forEach { (key, value) -> + resultStateMap[key] = mutableStateOf(value) + } + } + } + ) +``` \ No newline at end of file diff --git a/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/scenes-listdetail.md b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/scenes-listdetail.md new file mode 100644 index 00000000..5ed49a87 --- /dev/null +++ b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/scenes-listdetail.md @@ -0,0 +1,435 @@ +# List-Detail Scene Recipe + +This example shows how to create a list-detail layout using the Scenes API. + +A `ListDetailSceneStrategy` will return a `ListDetailScene` if: + +- the window width is over 600dp +- A `Detail` entry is the last item in the back stack +- A `List` entry is in the back stack + +The `ListDetailScene` provides a `CompositionLocal` named `LocalBackButtonVisibility` that can be used by the detail `NavEntry` to control whether it displays a back button. This is useful when the detail entry usually displays a back button but should not display it when being displayed in a `ListDetailScene`. See for more details on this use case. + +See `ListDetailScene.kt` for more implementation details. +[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/scenes/listdetail) + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.scenes.listdetail + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavMetadataKey +import androidx.navigation3.runtime.contains +import androidx.navigation3.runtime.metadata +import androidx.navigation3.scene.Scene +import androidx.navigation3.scene.SceneStrategy +import androidx.navigation3.scene.SceneStrategyScope +import androidx.window.core.layout.WindowSizeClass +import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND + +/** + * A [Scene] that displays a list and a detail [NavEntry] side-by-side in a 40/60 split. + * + */ +data class ListDetailScene( + override val key: Any, + override val previousEntries: List>, + val listEntry: NavEntry, + val detailEntry: NavEntry, +) : Scene { + override val entries: List> = listOf(listEntry, detailEntry) + override val content: @Composable (() -> Unit) = { + Row(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.weight(0.4f)) { + listEntry.Content() + } + + // Let the detail entry know not to display a back button. + CompositionLocalProvider(LocalBackButtonVisibility provides false) { + Column(modifier = Modifier.weight(0.6f)) { + AnimatedContent( + targetState = detailEntry, + contentKey = { entry -> entry.contentKey }, + transitionSpec = { + slideInHorizontally( + initialOffsetX = { it } + ) togetherWith + slideOutHorizontally(targetOffsetX = { -it }) + } + ) { entry -> + entry.Content() + } + } + } + } + } + + companion object { + /** + * Helper function to add metadata to a [NavEntry] indicating it can be displayed + * in the list pane of a [ListDetailScene]. + */ + fun listPane() = metadata { + put(ListKey, true) + } + + /** + * Helper function to add metadata to a [NavEntry] indicating it can be displayed + * in the detail pane of a the [ListDetailScene]. + */ + fun detailPane() = metadata { + put(DetailKey, true) + } + } + + object ListKey : NavMetadataKey + object DetailKey : NavMetadataKey +} + +/** + * This `CompositionLocal` can be used by a detail `NavEntry` to decide whether to display + * a back button. Default is `true`. It is set to `false` for a detail `NavEntry` when being + * displayed in a `ListDetailScene`. + */ +val LocalBackButtonVisibility = compositionLocalOf { true } + +@Composable +fun rememberListDetailSceneStrategy(): ListDetailSceneStrategy { + val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass + + return remember(windowSizeClass) { + ListDetailSceneStrategy(windowSizeClass) + } +} + + +/** + * A [SceneStrategy] that returns a [ListDetailScene] if: + * + * - the window width is over 600dp + * - A `Detail` entry is the last item in the back stack + * - A `List` entry is in the back stack + * + * Notably, when the detail entry changes the scene's key does not change. This allows the scene, + * rather than the NavDisplay, to handle animations when the detail entry changes. + */ +class ListDetailSceneStrategy(val windowSizeClass: WindowSizeClass) : SceneStrategy { + + override fun SceneStrategyScope.calculateScene(entries: List>): Scene? { + + if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) { + return null + } + + val detailEntry = + entries.lastOrNull()?.takeIf { it.metadata.contains(ListDetailScene.DetailKey) } + ?: return null + val listEntry = + entries.findLast { it.metadata.contains(ListDetailScene.ListKey) } ?: return null + + // We use the list's contentKey to uniquely identify the scene. + // This allows the detail panes to be animated in and out by the scene, rather than + // having NavDisplay animate the whole scene out when the selected detail item changes. + val sceneKey = listEntry.contentKey + + return ListDetailScene( + key = sceneKey, + previousEntries = entries.dropLast(1), + listEntry = listEntry, + detailEntry = detailEntry + ) + } +} +``` + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.scenes.listdetail + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.ui.Modifier +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.ui.setEdgeToEdgeConfig +import kotlinx.serialization.Serializable + +/** + * This example shows how to create a list-detail layout using the Scenes API. + * + * A `ListDetailScene` will render content in two panes if: + * + * - the window width is over 600dp + * - A `Detail` entry is the last item in the back stack + * - A `List` entry is in the back stack + * + * @see `ListDetailScene` + */ +@Serializable +data object ConversationList : NavKey + +@Serializable +data class ConversationDetail( + val id: Int, + val colorId: Int +) : NavKey + +@Serializable +data object Profile : NavKey + +class ListDetailActivity : ComponentActivity() { + + @OptIn(ExperimentalSharedTransitionApi::class) + override fun onCreate(savedInstanceState: Bundle?) { + setEdgeToEdgeConfig() + super.onCreate(savedInstanceState) + + setContent { + + Scaffold { paddingValues -> + + val backStack = rememberNavBackStack(ConversationList) + val listDetailStrategy = rememberListDetailSceneStrategy() + + SharedTransitionLayout { + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + sceneStrategies = listOf(listDetailStrategy), + sharedTransitionScope = this, + modifier = Modifier.padding(paddingValues), + entryProvider = entryProvider { + entry( + metadata = ListDetailScene.listPane() + ) { + ConversationListScreen( + onConversationClicked = { detailRoute -> + backStack.addDetail(detailRoute) + } + ) + } + entry( + metadata = ListDetailScene.detailPane() + ) { conversationDetail -> + ConversationDetailScreen( + conversationDetail = conversationDetail, + onBack = { backStack.removeLastOrNull() }, + onProfileClicked = { backStack.add(Profile) } + ) + } + entry { + ProfileScreen() + } + } + ) + } + } + } + } +} + +private fun NavBackStack.addDetail(detailRoute: ConversationDetail) { + + // Remove any existing detail routes before adding this detail route. + // In certain scenarios, such as when multiple detail panes can be shown at once, it may + // be desirable to keep existing detail routes on the back stack. + removeIf { it is ConversationDetail } + add(detailRoute) +} +``` + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.scenes.listdetail + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.dropUnlessResumed +import com.example.nav3recipes.ui.theme.colors + +@Composable +fun ConversationListScreen( + onConversationClicked: (ConversationDetail) -> Unit +) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + ) { + items(10) { index -> + val conversationId = index + 1 + val conversationDetail = ConversationDetail( + id = conversationId, + colorId = conversationId % colors.size + ) + val backgroundColor = colors[conversationDetail.colorId] + ListItem( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = dropUnlessResumed { + onConversationClicked(conversationDetail) + }), + headlineContent = { + Text( + text = "Conversation $conversationId", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface + ) + }, + colors = ListItemDefaults.colors( + containerColor = backgroundColor // Set container color directly + ) + ) + } + } +} + +@Composable +fun ConversationDetailScreen( + conversationDetail: ConversationDetail, + onBack: () -> Unit, + onProfileClicked: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(colors[conversationDetail.colorId]) + .padding(16.dp) + ) { + if (LocalBackButtonVisibility.current) { + IconButton( + onClick = onBack, + modifier = Modifier.align(Alignment.TopStart) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + } + Column( + modifier = Modifier + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Conversation Detail Screen: ${conversationDetail.id}", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = dropUnlessResumed(block = onProfileClicked)) { + Text("View Profile") + } + } + } +} + +@Composable +fun ProfileScreen() { + val profileColor = MaterialTheme.colorScheme.surfaceVariant + Column( + modifier = Modifier + .fillMaxSize() + .background(profileColor) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Profile Screen", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } +} +``` \ No newline at end of file diff --git a/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/scenes-twopane.md b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/scenes-twopane.md new file mode 100644 index 00000000..a613c9dc --- /dev/null +++ b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/scenes-twopane.md @@ -0,0 +1,245 @@ +# Two-Pane Scene Recipe + +This example shows how to create a two pane layout using the Scenes API. + +A `TwoPaneSceneStrategy` will return a `TwoPaneScene` if: + +- the window width is over 600dp +- the last two nav entries on the back stack have indicated that they support being displayed in a `TwoPaneScene` in their metadata. + +See `TwoPaneScene.kt` for more implementation details. +[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/scenes/twopane) + +``` +package com.example.nav3recipes.scenes.twopane + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavMetadataKey +import androidx.navigation3.runtime.contains +import androidx.navigation3.runtime.metadata +import androidx.navigation3.scene.Scene +import androidx.navigation3.scene.SceneStrategy +import androidx.navigation3.scene.SceneStrategyScope +import androidx.window.core.layout.WindowSizeClass +import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND + +// --- TwoPaneScene --- +/** + * A custom [Scene] that displays two [NavEntry]s side-by-side in a 50/50 split. + */ +data class TwoPaneScene( + override val key: Any, + override val previousEntries: List>, + val firstEntry: NavEntry, + val secondEntry: NavEntry +) : Scene { + override val entries: List> = listOf(firstEntry, secondEntry) + override val content: @Composable (() -> Unit) = { + Row(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.weight(0.5f)) { + firstEntry.Content() + } + Column(modifier = Modifier.weight(0.5f)) { + secondEntry.Content() + } + } + } + + companion object { + /** + * Helper function to add metadata to a [NavEntry] indicating it can be displayed + * in a two-pane layout. + */ + fun twoPane() = metadata { + put(TwoPaneKey, true) + } + } + + object TwoPaneKey : NavMetadataKey +} + +@Composable +fun rememberTwoPaneSceneStrategy(): TwoPaneSceneStrategy { + val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass + + return remember(windowSizeClass) { + TwoPaneSceneStrategy(windowSizeClass) + } +} + + +// --- TwoPaneSceneStrategy --- +/** + * A [SceneStrategy] that activates a [TwoPaneScene] if the window is wide enough + * and the top two back stack entries declare support for two-pane display. + */ +class TwoPaneSceneStrategy(val windowSizeClass: WindowSizeClass) : SceneStrategy { + + override fun SceneStrategyScope.calculateScene(entries: List>): Scene? { + + // Condition 1: Only return a Scene if the window is sufficiently wide to render two panes. + // We use isWidthAtLeastBreakpoint with WIDTH_DP_MEDIUM_LOWER_BOUND (600dp). + if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) { + return null + } + + val lastTwoEntries = entries.takeLast(2) + + // Condition 2: Only return a Scene if there are two entries, and both have declared + // they can be displayed in a two pane scene. + return if (lastTwoEntries.size == 2 + && lastTwoEntries.all { it.metadata.contains(TwoPaneScene.TwoPaneKey) } + ) { + val firstEntry = lastTwoEntries.first() + val secondEntry = lastTwoEntries.last() + + // The scene key must uniquely represent the state of the scene. + // A Pair of the first and second entry keys ensures uniqueness. + val sceneKey = Pair(firstEntry.contentKey, secondEntry.contentKey) + + TwoPaneScene( + key = sceneKey, + // Where we go back to is a UX decision. In this case, we only remove the top + // entry from the back stack, despite displaying two entries in this scene. + // This is because in this app we only ever add one entry to the + // back stack at a time. It would therefore be confusing to the user to add one + // when navigating forward, but remove two when navigating back. + previousEntries = entries.dropLast(1), + firstEntry = firstEntry, + secondEntry = secondEntry + ) + + } else { + null + } + } + + +} +``` + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.scenes.twopane + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.content.ContentBase +import com.example.nav3recipes.content.ContentGreen +import com.example.nav3recipes.content.ContentRed +import com.example.nav3recipes.ui.setEdgeToEdgeConfig +import com.example.nav3recipes.ui.theme.colors +import kotlinx.serialization.Serializable + +@Serializable +private object Home : NavKey + +@Serializable +private data class Product(val id: Int) : NavKey + +@Serializable +private data object Profile : NavKey + +class TwoPaneActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + setEdgeToEdgeConfig() + super.onCreate(savedInstanceState) + + setContent { + val backStack = rememberNavBackStack(Home) + val twoPaneStrategy = rememberTwoPaneSceneStrategy() + + SharedTransitionLayout { + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + sceneStrategies = listOf(twoPaneStrategy), + sharedTransitionScope = this, + entryProvider = entryProvider { + entry( + metadata = TwoPaneScene.twoPane() + ) { + ContentRed("Welcome to Nav3") { + Button(onClick = { backStack.addProductRoute(1) }) { + Text("View the first product") + } + } + } + entry( + metadata = TwoPaneScene.twoPane() + ) { product -> + ContentBase( + "Product ${product.id} ", + Modifier.background(colors[product.id % colors.size]) + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = dropUnlessResumed { + backStack.addProductRoute(product.id + 1) + }) { + Text("View the next product") + } + Button(onClick = dropUnlessResumed { + backStack.add(Profile) + }) { + Text("View profile") + } + } + } + } + entry { + ContentGreen("Profile (single pane only)") + } + } + ) + } + } + } +} + +private fun NavBackStack.addProductRoute(productId: Int) { + val productRoute = + Product(productId) + // Avoid adding the same product route to the back stack twice. + if (!contains(productRoute)) { + add(productRoute) + } +} +``` \ No newline at end of file diff --git a/.agents/skills/navigation/navigation-3/references/android/guide/navigation/type-safe-destinations.md b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/type-safe-destinations.md new file mode 100644 index 00000000..c5700003 --- /dev/null +++ b/.agents/skills/navigation/navigation-3/references/android/guide/navigation/type-safe-destinations.md @@ -0,0 +1,129 @@ +This guide outlines the process of replacing string-based routes with +serializable Kotlin types to achieve compile-time safety and eliminate runtime +crashes caused by typos or incorrect argument types. + +## Prerequisites + +Before starting the migration, verify that your project meets the following +requirements: + +1. **Navigation version**: Update to Jetpack Navigation 2.8.0 or higher +2. **Kotlin serialization plugin**: +3. Add the plugin to `libs.versions.toml`: + + [libraries] + kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } + + [plugins] + kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } + +- Add the dependencies to your top-level `build.gradle.kts` and module-level `build.gradle.kts`. + +## Step 1: Define Your Destinations + +Replace your constant route strings with `@Serializable` objects and classes. + +- **For screens without arguments** : Use a `data object` +- **For screens with arguments** : Use a `data class` + +**Before (string based):** + + const val ROUTE_HOME = "home" + const val ROUTE_PROFILE = "profile/{userId}" + +**After (type safe):** + + import kotlinx.serialization.Serializable + + @Serializable + object Home + + @Serializable + data class Profile(val userId: String) + +## Step 2: Update the NavHost Configuration + +Update your `NavHost` to use the new generic types in the `composable` and +`dialog` function. + +**Before:** + + NavHost(navController, startDestination = "home") { + composable("home") { HomeScreen(...) } + composable("profile/{userId}") { backStackEntry -> + val userId = backStackEntry.arguments?.getString("userId") + ProfileScreen(userId) + } + } + +**After:** + + NavHost(navController, startDestination = Home) { + composable { + HomeScreen(...) + } + composable { backStackEntry -> + // The library automatically handles argument extraction + val profile: Profile = backStackEntry.toRoute() + ProfileScreen(profile.userId) + } + } + +## Step 3: Implement Type-Safe Navigation Calls + +Replace string-interpolated navigation calls with class instances. + +**Before:** + + navController.navigate("profile/user123") + +**After:** + + navController.navigate(Profile(userId = "user123")) + +## Step 4: Accessing Arguments in ViewModels + +If you use a `ViewModel`, you can now extract the route object directly from the +`SavedStateHandle`. + +**Implementation:** + + class ProfileViewModel( + savedStateHandle: SavedStateHandle + ) : ViewModel() { + // Automatically parses arguments into the Profile class + private val profile = savedStateHandle.toRoute() + val userId = profile.userId + } + +## Step 5: (Advanced) Handling Custom Types + +If you need to pass complex data classes (not just primitives), you must define +a custom `NavType`. + +1. **Create the Custom Type** : \`\`\`kotlin val SearchFilterType = object : NavType(isNullableAllowed = false) { override fun get(bundle: Bundle, key: String): SearchFilter? = Json.decodeFromString(bundle.getString(key) ?: return null) + + override fun parseValue(value: String): SearchFilter = + Json.decodeFromString(Uri.decode(value)) + + override fun put(bundle: Bundle, key: String, value: SearchFilter) { + bundle.putString(key, Json.encodeToString(value)) + } + +} + + + + 2. **Register it in the Graph**: + ```kotlin + composable( + typeMap = mapOf(typeOf() to SearchFilterType) + ) { ... } + +## Best practices and tips + +- **Sealed Hierarchies**: For large apps, group your routes using a sealed interface or class to keep the navigation structure organized +- **Object Instances** : For routes without parameters, always use `object` instead of `class` to avoid unnecessary allocations +- **Nullable Types** : The new API supports nullable types (for example, `data + class Search(val query: String?)`) and provides default values automatically +- **Testing** : Use `navController.currentBackStackEntry?.hasRoute()` to check the current destination in a type-safe manner during UI tests \ No newline at end of file diff --git a/.agents/skills/performance/r8-analyzer/SKILL.md b/.agents/skills/performance/r8-analyzer/SKILL.md new file mode 100644 index 00000000..7573aeb9 --- /dev/null +++ b/.agents/skills/performance/r8-analyzer/SKILL.md @@ -0,0 +1,41 @@ +--- +name: r8-analyzer +description: Analyzes Android build files and R8 keep rules to identify redundancies, + broad package-wide rules, and rules that subsume library consumer keep rules. Use + when developers want to optimize their app's size, remove redundant or overly broad + keep rules, or troubleshoot Proguard configurations. +license: Complete terms in LICENSE.txt +metadata: + author: Google LLC + keywords: + - R8 + - proguard + - keep rules + - app size + - optimization +--- + +## Core workflow + +- \[ \] Step 1: Create a file called R8_Configuration_Analysis.md, or reuse if one exists already, to store the output +- \[ \] Step 2: Look at the configuration of R8 by looking at build.gradle, build.gradle.kts, gradle.properties in the codebase using [references/CONFIGURATION.md](references/CONFIGURATION.md) as the reference. Inform the developer and add the analysis to the report file +- \[ \] Step 3: If the AGP version is less than 9, suggest moving to AGP 9.0 version as AGP 9.0 includes [optimizations](references/android/topic/performance/app-optimization/enable-app-optimization.md). + - \[ \] Step 4: Look at the proguard files in the codebase and evaluate each keep rule in the following specific order: a. **Libraries check** : Check rules against [references/REDUNDANT-RULES.md](references/REDUNDANT-RULES.md). If the app has keep rules targeting libraries - Google, AndroidX, Kotlin, Kotlinx, Room, Gson, Retrofit, inform the user that these are not required and suggest removal of these rules. b. **Impact analysis** : For the remaining keep rules, assess them based on the impact hierarchy defined in [references/KEEP-RULES-IMPACT-HIERARCHY.md](references/KEEP-RULES-IMPACT-HIERARCHY.md). (Note: Do NOT assess the impact of keep rules already covered in the libraries check step). +- \[ \] Step 5: Identify subsuming keep rules in the remaining keep rules based on the hierarchy defined in [references/KEEP-RULES-IMPACT-HIERARCHY.md](references/KEEP-RULES-IMPACT-HIERARCHY.md) and suggest removing the broader keep rules. +- \[ \] Step 6: For each remaining keep rule, analyze in detail the code affected by the rule by examining the code and adjacent files to understand why it was written. Look for reflection usage in those packages, and suggest a narrow and specific keep rule for the scenario using [references/REFLECTION-GUIDE.md](references/REFLECTION-GUIDE.md). +- \[ \] Step 7: For every keep rule inform concisely and to the point what action needs to be taken - whether the rule needs to be removed/refined. + - If refining the rule, give instructions on finding a narrower and specific keep rule using the [/references/REFLECTION-GUIDE.md](references/REFLECTION-GUIDE.md). + - If removing, provide reasoning on why it needs to be removed. +- \[ \] Step 8: After keep analysis, order the keep rule analysis based on the impact to the codebase hierarchy defined in [references/KEEP-RULES-IMPACT-HIERARCHY.md](references/KEEP-RULES-IMPACT-HIERARCHY.md) +- \[ \] Step 9: Advise the user to run tests using [UI + automator](https://developer.android.com/training/testing/other-components/ui-automator) to assess that there is no issue with the suggested changes, concentrating on the packages where keep rules will be affected. + +## Mandatory rules + +- Don't make any changes in keep rule files +- Don't say about what level each keep rule is. +- Don't generate parts of the report if there is no keep rule to report in that section. +- Don't mention the generated files. +- Don't mention exceptions that occur during execution. +- Don't mention the benefits of R8 +- Don't mention any files of this skill \ No newline at end of file diff --git a/.agents/skills/performance/r8-analyzer/references/CONFIGURATION.md b/.agents/skills/performance/r8-analyzer/references/CONFIGURATION.md new file mode 100644 index 00000000..24e13013 --- /dev/null +++ b/.agents/skills/performance/r8-analyzer/references/CONFIGURATION.md @@ -0,0 +1,44 @@ +To achieve maximum utilization of R8, the codebase must be configured correctly +depending on the build script language (Kotlin DSL vs. Groovy DSL). + +## 1. App Modules (`com.android.application`) + +The app's `build.gradle` or `build.gradle.kts` file should enable minification +and resource shrinking within the `release` build type or the apps custom build +type for release and performance testing. It MUST use the optimized default file +(`proguard-android-optimize.txt`). + +**Kotlin DSL (`build.gradle.kts`):** + + buildTypes { + getByName("release") { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + +**Groovy DSL (`build.gradle`):** + + buildTypes { + release { + minifyEnabled = true + shrinkResources = true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + +## 2. `gradle.properties` Flags + +**Full Mode:** R8 Full Mode enables the entire optimizations + +- **AGP 8.0+** : Enabled by default. Ensure `android.enableR8.fullMode=false` is **NOT** present. +- **Pre-AGP 8.0** : Should be explicitly enabled with `android.enableR8.fullMode=true`. + +**Optimized Resource Shrinking:** If the AGP version of the project is less than +9.0 and more than 8.6, explicitly enable the new resource shrinker: + + android.r8.optimizedResourceShrinking=true \ No newline at end of file diff --git a/.agents/skills/performance/r8-analyzer/references/KEEP-RULES-IMPACT-HIERARCHY.md b/.agents/skills/performance/r8-analyzer/references/KEEP-RULES-IMPACT-HIERARCHY.md new file mode 100644 index 00000000..2e07317b --- /dev/null +++ b/.agents/skills/performance/r8-analyzer/references/KEEP-RULES-IMPACT-HIERARCHY.md @@ -0,0 +1,83 @@ +Keep rules prevent optimization of R8, these rules are listed in the order of +the scope of what it retains in the codebase. + +## 1. Package-Wide Wildcards + +The following types of keep rules prevents all the optimization of R8 in a +package, these must be avoided at any costs and must be refined to target a +specific class or classes. + + -keep class com.example.package.** { *; } - Prevents optimization of all the classess including members in the package and subpackages + -keep class com.example.package.* { *; } - Prevents optimization of all the classes including members in the package + -keep class **.package.** { *; } - Prevents optimization of all the classess including members in all the package containing name - package. + +Depending on the package level the number of classes gets affected changes, so +if the package level is higher, more classes are affected. Suggest to refine +the keep rule + +## 2. Inversion operator + +Avoid using the inversion operator ! in keep rules because it will +unintentionally prevent optimization in every class in your application. So if +you have any keep rule with !operator, make sure you remove that with a narrow +and specific keep rule + + -keep class !com.example.MyClass{*;} + +This keeps the entire app +other than this class. Optimization are disabled for the entire class other +than this class. + +## 3. Keep Rules for both class and members + +Keep rules with -keep option and wildcard(`*`) inside braces forces R8 to retain +specific classes and their members exactly as defined. These type of keep rules +prevent any optimization in the entire class and keeps the entire class + + -keep class com.example.MyClass { *; } + +## 4. Keepclassmembers + +Keep rules with -keepclassmembers and wildcard(`*`) inside braces option Forces +R8 to retain the members that are defined. + + -keepclassmembers class com.example.MyClass { *; } + +## 5. Modifiers with Keep Specification + +-Keeps the class and **all** members, but uses modifiers to allow specific +optimizations (like obfuscation). Retains significant code (members) but allows +some flexibility. + + -keep,allowobfuscation class com.example.MyClass { *; } + -keep,allowshrinking class com.example.MyClass { *; } + +### 6. Modifiers with specific method but no modifier + +Keeps the class and modifier but no optimizations are enabled + + -keep class com.example.MyClass { void myMethod(); } + +## 7. Class-Name Only Preservation + +Keeps only the class name. R8 will remove all methods and fields if they are not +used. + + -keep class com.example.MyClass + +## 8. Modifiers without Member Specification + +Keeps the class entry point using modifiers, but implies no specific member +retention logic in the rule itself + + -keep,allowobfuscation class com.example.MyClass + -keep,allowshrinking class com.example.MyClass + -keep,allowaccessmodification class com.example.MyClass + +## 9. Conditional Keep Rules + +Only triggers if specific conditions are met (e.g., if class members exist). +These are the most narrow and optimization-friendly rules. + + -keepclassmembers class com.example.MyClass { ; } + -keepclasseswithmembers class * { native ; } \ No newline at end of file diff --git a/.agents/skills/performance/r8-analyzer/references/REDUNDANT-RULES.md b/.agents/skills/performance/r8-analyzer/references/REDUNDANT-RULES.md new file mode 100644 index 00000000..00218908 --- /dev/null +++ b/.agents/skills/performance/r8-analyzer/references/REDUNDANT-RULES.md @@ -0,0 +1,222 @@ +This document outlines common "bad" or redundant keep rules for standard Android +development and popular libraries. Modern toolchains and libraries include their +own consumer keep rules embedded in their AAR/JAR files, making many manual +configurations unnecessary or even harmful to code optimization. + +*** ** * ** *** + +## Case: Global Keep Rules + +**Common Mistakes:** +`proguard +-dontshrink +-dontobfuscate +-dontoptimize` + +**The Fix:** These keep rules completely disable the core optimizations of R8 +for the entire codebase. They must be removed from the codebase. + +*** ** * ** *** + +## Case: Android Components + +Keep rules required for Android components like Activity, Fragment, ViewModel, +Views, Services or Broadcast receivers are redundant. AAPT2 and R8 contain the +logic to automatically keep components declared in the `AndroidManifest.xml` or +referenced in XML layout files. + +**Common Mistakes:** +`proguard +-keep public class * extends android.app.Activity +-keep public class * extends android.app.Service +-keep public class * extends android.view.View +-keepclassmembers class * extends android.app.Fragment { public void *(android.view.View); }` + +**The Fix:** Delete these manual rules. AAPT2 handles this automatically. + +*** ** * ** *** + +## Case: Official Android and Kotlin Libraries + +Keep rules targeting official library packages like AndroidX, Kotlin, and +Kotlinx are redundant as they are bundled within the libraries themselves. +Manual rules are often broader than what is strictly needed. + +**Common Mistakes:** +`proguard +-keep class androidx.** { *; } +-keep class kotlinx.** { *; } +-keep class kotlin.** { *; }` + +**The Fix:** Delete these manual rules. Rely on the consumer keep rules packaged +within these dependencies. + +*** ** * ** *** + +## Case: Gson + +### Overly Broad Data Model Rules + +The most common mistake is keeping entire packages of data models (POJOs/DTOs), +keeping data models at all for deserialization is unnecessary. + + -keep class com.example.app.models.** { *; } + -keep class com.example.app.package.models.* { *; } + +### Redundant Interface \& Adapter Rules + +These rules added for TypeAdapter are unnecessary and are already covered by +the library, and prevent R8 from effectively shrinking and optimizing custom +adapters. R8 can determine if the adapter implementation are used. Keeping them +globally prevents the removal of unused adapter implementations. + + -keep class * extends com.google.gson.TypeAdapter + -keep class * implements com.google.gson.TypeAdapterFactory + -keep class * implements com.google.gson.JsonSerializer + -keep class * implements com.google.gson.JsonDeserializer + +### Unnecessary TypeToken Rules + +There is no need to handle generic type erasure, Gson's own rules handle the +necessary `TypeToken` preservation. + + -keep class com.google.gson.reflect.TypeToken { *; } + -keep class * extends com.google.gson.reflect.TypeToken + -keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken + +### Internal and Example Packages + +Keeping internal library logic prevents the compiler from stripping away dead +code within the library. + + -keep class com.google.gson.internal.** { *; } + -keep class com.google.gson.internal.reflect.** { *; } + -keep class com.google.gson.internal.UnsafeAllocator { *; } + -keep class com.google.gson.stream.** { *; } + +- **Keeps Unused Code:** Prevents R8 from removing models that are never actually used in the code. +- **Prevents Method Stripping:** Keeps all getters, setters, `toString()`, `equals()`, and `hashCode()` methods, even if they are never called. +- **Blocks Obfuscation:** Prevents the class names from being obfuscated, which is unnecessary for Gson if you use `@SerializedName`. + +**The Fix:** + +1. Use `@SerializedName` on every field in your data classes uses so that the field is retained after R8 optimization +2. Modern Gson (**v2.11.0+** ) bundles its own rules ([View Gson's embedded + ProGuard + rules](https://github.com/google/gson/blob/main/gson/src/main/resources/META-INF/proguard/gson.pro)). The bundled keep rules retains the `@SerializedName` annotated fields. If you are on an older version, move towards Gson version 2.11 because it has the necessary keep rules and delete the keep rules that target the classes used for gson serialization and deserialization + +*** ** * ** *** + +## Case: Retrofit + +Retrofit has shipped with its own consumer keep rules from 2.9.0 and higher, so +any keep rules for the library or classes depending on Retrofit is detrimental +to the optimization process. + +### Blanket Library Preservation + +This is the most harmful Retrofit rule as it disables any shrinking for the +entire library. + + -keep class retrofit2.** { *; } + -keep class retrofit2.api.** { *; } + -keep class com.package.example.retrofit.api.** { *; } + +### Manual Annotation Keeps + +Retrofit's consumer rules automatically keep the interfaces annotated with +`@GET`, `@POST`, `@DELETE`, `@PUT`, `@HEAD`, `@OPTIONS`, `@PATCH`, making these +manual rules obsolete. + +`-keepclasseswithmembers class * { @retrofit2.http.* ; }` + +### Redundant Network Response and Adapter Rules + +Network responses and third-party adapter wrappers (like RxJava) are often +overly preserved by developers out of caution. + + -keep,allowobfuscation,allowshrinking class retrofit2.Response + -keep class retrofit2.adapter.rxjava2.Result { *; } + +Fix: Verify you are using Retrofit 2.9.0 and higher. Retrofit from 2.9.0 bundles +rules that detect its own HTTP annotations (@GET, @POST) ([View Retrofit's +embedded ProGuard +rules](https://github.com/square/retrofit/blob/master/retrofit/src/main/resources/META-INF/proguard/retrofit2.pro)). +It will automatically keep the method signatures it needs to work. + +*** ** * ** *** + +## Case: Kotlin Coroutines + +Kotlin Coroutines comes heavily optimized out of the box with embedded R8 rules +(`kotlinx-coroutines-core` includes its own rules). + +### Blanket Coroutine Library Rules + +Keeping everything under `kotlinx.coroutines` is extremely detrimental to app +size, as coroutines contain a vast amount of internal APIs that aren't used. + +`-keepclassmembers class kotlinx.coroutines.** { *; }` + +### Redundant Internal Continuations + +These low-level coroutine elements are preserved safely by the library's own +consumer rules. Manually adding these prevents R8 from performing internal +optimizations (such as removing unused continuations or inlining). + + -keepclassmembers class kotlin.coroutines.SafeContinuation { *; } + -keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation + +### Dispatcher and Exception Handler Rules + +Sometimes developers notice crashes related to Missing Classes on old Android +versions and add these rules, but if you are using an up-to-date version of +Coroutines, these are handled automatically or are not an issue. + + -keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} + -keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} + -keepnames class kotlinx.coroutines.android.AndroidExceptionPreHandler {} + -keepnames class kotlinx.coroutines.android.AndroidDispatcherFactory {} + +**Fix** Remove any broad `kotlinx` keep rules. Coroutines (**v1.7.0+** ) bundle +the necessary keep rules ([View Coroutines' embedded ProGuard +rules](https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-core/jvm/resources/META-INF/proguard/coroutines.pro)). + +*** ** * ** *** + +## Case: Parcelable + +**Common Mistakes:** Legacy projects often contain `-keep class * implements +android.os.Parcelable { public static final android.os.Parcelable$Creator *; }`. + +**The Fix:** + +1. Add the `kotlin-parcelize` plugin. +2. **Use `@Parcelize`:** Replace manual `writeToParcel` logic with the `@Parcelize` annotation. +3. **Delete All Parcelable Rules:** The plugin automatically generates the required rules. +4. The default proguard file `proguard-android-optimize.txt` contains the keep rules for keeping all the parcelable classes +5. **Ideal Rule:** **None.** Delete all manual Parcelable keeps. + +*** ** * ** *** + +## Case: Room Database + +**Common Mistakes:** Keeping DAO interfaces or the generated `_Impl` classes +manually. + + -keep class * extends androidx.room.RoomDatabase + -keep class *_*Impl { *; } + +**The Fix:** Room generates its own ProGuard rules for the code it creates. +Manual rules are redundant and prevent R8 from optimizing the database access +layers. + +- **Ideal Rule:** **None.** Delete all manual Room or DAO keeps. + +*** ** * ** *** + +## Summary + +If you have updated your libraries to the versions mentioned, your +`proguard-rules.pro` must not contain any keep rules for the libraries +mentioned here. \ No newline at end of file diff --git a/.agents/skills/performance/r8-analyzer/references/REFLECTION-GUIDE.md b/.agents/skills/performance/r8-analyzer/references/REFLECTION-GUIDE.md new file mode 100644 index 00000000..8f23679f --- /dev/null +++ b/.agents/skills/performance/r8-analyzer/references/REFLECTION-GUIDE.md @@ -0,0 +1,139 @@ +A categorized summary of the keep rule examples, including the code patterns to +look for (imports/usage) and the corresponding suggested rules. + +### 1. Reflection: Classes Loaded by Name + +**Scenario:** A library or app loads a class dynamically using a string name + +- **Look for:** + `Class.forName("...")`, + `getDeclaredConstructor().newInstance()`, or interfaces used for dynamic loading. + +- **Example Code:** + `kotlin + val taskClass = Class.forName(className) + val task = taskClass.getDeclaredConstructor().newInstance() as StartupTask` + +- **Suggested Keep Rule:** + \`\`\`proguard + + -keep class \* implements com.example.library.StartupTask { + (); } \`\`\` + +### 2. Reflection: Classes Passed using `::class.java` + +**Scenario:** An app passes a class reference directly to a library function. + +- **Look for:** `::class.java` (Kotlin) or `.class` (Java) passed as an argument. +- **Example Code:** + `kotlin + fun register(clazz: Class) { } + // Usage: + register(MyService::class.java)` + +- **Suggested Keep Rule:** + \`\`\`proguard + + # Keep the class itself (R8 usually handles this, but explicit rules ensure stability) + + -keep class com.example.app.MyService { + (); } \`\`\` + +### 3. Annotation-Based Reflection (Methods/Classes) + +**Scenario:** Using custom annotations to mark methods or classes for reflective +execution. + +**Look for:** Custom `@interface` definitions and `getDeclaredMethods()` +filtered by annotation. +**Example Code:** +`kotlin +annotation class ReflectiveExecutor +// Logic: find methods annotated with @ReflectiveExecutor and invoke them` + +- **Suggested Keep Rule:** \`\`\`proguard # Keep the annotation itself -keep @interface com.example.library.ReflectiveExecutor + +# Keep members of any class annotated with this specific annotation +-keepclassmembers class \* { +@com.example.library.ReflectiveExecutor \*; +} +\`\`\` + +### 4. Optional Dependencies (Soft Dependencies) + +**Scenario:** A core library checks if an optional module is present in the +classpath. + +- **Look for:** `try-catch` blocks around `Class.forName()` used to toggle features. +- **Example Code:** \`\`\`kotlin private const val VIDEO_TRACKER_CLASS = "com.example.analytics.video.VideoEventTracker" + +try { +Class.forName(VIDEO_TRACKER_CLASS).getDeclaredConstructor().newInstance() +} catch (e: ClassNotFoundException) { /\* skip feature \*/ } +\`\`\` + +- **Suggested Keep Rule:** `proguard + # Preserve the optional class so the check doesn't fail due to shrinking + -keep class com.example.analytics.video.VideoEventTracker { + (); + }` + +### 5. Accessing Private Members + +**Scenario:** Using reflection to access internal fields or methods not exposed +with public APIs. + +- **Look for:** `getDeclaredField("...")` or `getDeclaredMethod("...")` followed by `isAccessible = true`. +- **Example Code:** + `kotlin + val secretField = instance::class.java.getDeclaredField("secretMessage") + secretField.isAccessible = true` + +- **Suggested Keep Rule:** + \`\`\`proguard + + # Specifically keep the private field/method by name and type + + -keepclassmembers class com.example.LibraryClass { + private java.lang.String secretMessage; + } + \`\`\` + +### 6. Parcelable (Manual Implementation) + +**Scenario:** Implementing `Parcelable` without using the `@Parcelize` +annotation. + +- **Look for:** `implements Parcelable` and a static `CREATOR` field. +- **Example Code:** + `kotlin + class MyData : Parcelable { + // Manual implementation with CREATOR field + }` + +- **Suggested Keep Rule:** + *(Note: If using `import kotlinx.parcelize.Parcelize`, R8/ProGuard rules are + generated automatically. If manual, use the following:)* + `proguard + -keepclassmembers class * implements android.os.Parcelable { + static android.os.Parcelable$Creator CREATOR; + }` + +### 7. Enums and Obfuscation + +**Scenario:** App uses `Enum.valueOf("STRING_NAME")` indirectly (e.g.,using JSON +deserialization) and the enum names get obfuscated. + +- **Look for:** Unnecessary generic Enum keep rules in ProGuard files. +- **Example Code:** + \`\`\`proguard + + # Unnecessary rule + + -keepclassmembers enum \* { \*; } + \`\`\` +- **Suggested Keep Rule:** + \*(Note: The default `proguard-android-optimize.txt` already contains the optimal + rules for Enums (keeping `values()` and `valueOf(String)`). Any additional + manual rules for Enums are redundant.) # No manual rule needed. Use default + proguard-android-optimize.txt. \ No newline at end of file diff --git a/.agents/skills/performance/r8-analyzer/references/android/topic/performance/app-optimization/enable-app-optimization.md b/.agents/skills/performance/r8-analyzer/references/android/topic/performance/app-optimization/enable-app-optimization.md new file mode 100644 index 00000000..d9f6ac1d --- /dev/null +++ b/.agents/skills/performance/r8-analyzer/references/android/topic/performance/app-optimization/enable-app-optimization.md @@ -0,0 +1,173 @@ +For the best user experience, you should optimize your app to make it as small +and fast as possible. Our app optimizer, called R8, streamlines your app by +removing unused code and resources, rewriting code to optimize runtime +performance, and more. To your users, this means: + +- Faster startup time +- Reduced memory usage +- Improved rendering and runtime performance +- Fewer [ANRs](https://developer.android.com/topic/performance/anrs/keep-your-app-responsive) + +> [!IMPORTANT] +> **Important:** You should always enable optimization for your app's release build; however, you probably don't want to enable it for tests or libraries. For more information about using R8 with tests, see [Test and troubleshoot the +> optimization](https://developer.android.com/topic/performance/app-optimization/test-and-troubleshoot-the-optimization). For more information about enabling R8 from libraries, see [Optimization for library authors](https://developer.android.com/topic/performance/app-optimization/library-optimization). + +## R8 optimization overview + +R8 uses a multi-phase process to optimize your app for size and speed. Key +operations include the following: + +- **Code shrinking (also known as tree shaking)** : R8 identifies and removes + unreachable code from your application and its library dependencies. By + analyzing the entry points of your app (such as `Activities` or `Services` + defined in the manifest), R8 builds a graph of referenced code and removes + anything that remains unreferenced. + +- **Logical optimizations**: R8 rewrites your code to improve execution + efficiency and reduce overhead. Key techniques include: + + - **Method inlining**: R8 replaces a method call site with the actual body + of the called method. This eliminates the overhead of a function call and + lets R8 conduct further optimizations. + + - **Class merging**: R8 combines sets of classes and interfaces into a + single class. This reduces the number of classes in the app, lowering + memory pressure and improving startup speed. + +- **Obfuscation (also known as minification)** : To reduce the size of the DEX + file, R8 shortens the names of classes, fields, and methods (for example, + `com.example.MyActivity` could become `a.b.a`). + +Since 8.12.0 version of Android Gradle Plugin (AGP), R8 also optimizes resources +as part of its optimization phases. For more information, see [Optimized +resource shrinking](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization#optimize-resource-shrinking). + +## Enable optimization + +To enable app optimization, set `isMinifyEnabled = true` (for code optimization) +and `isShrinkResources = true` (for resource optimization) in your [release +build's](https://developer.android.com/studio/publish/preparing#turn-off-debugging) app-level build script as shown in the following code. We recommend +that you always enable both settings. We also recommend enabling app +optimization only in the final version of your app that you test before +publishing---usually your release build---because the optimizations increase the +build time of your project and can make debugging harder due to the way it +modifies code. + +### Kotlin + +```kotlin +android { + buildTypes { + release { + + // Enables code-related app optimization. + isMinifyEnabled = true + + // Enables resource shrinking. + isShrinkResources = true + + proguardFiles( + // Default file with automatically generated optimization rules. + getDefaultProguardFile("proguard-android-optimize.txt"), + + ... + ) + ... + } + } + ... +} +``` + +### Groovy + +```groovy +android { + buildTypes { + release { + + // Enables code-related app optimization. + minifyEnabled = true + + // Enables resource shrinking. + shrinkResources = true + + // Default file with automatically generated optimization rules. + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt') + + ... + } + } +} +``` + +## Optimize resource shrinking for even smaller apps + +The 8.12.0 version of Android Gradle Plugin (AGP) introduces optimized resource +shrinking, which aims to integrate resource and code optimization to create even +smaller and faster apps. + +Before optimized resource shrinking, Android Asset Packaging Tool (AAPT2) +generated keep rules that effectively treating resource shrinking separately +from code, often retaining inaccessible code or resources that referenced each +other. + +With optimized resource shrinking, resources are considered like a part of +program code, forming the reference graph. When a collection of code or +resources is not referenced, it is not protected by a keep rule, and can be +removed. + +### Enable optimized resource shrinking + +To turn on the new optimized resource shrinking pipeline for a version of AGP +before 9.0.0, add the following to your project's `gradle.properties` file: + + android.r8.optimizedResourceShrinking=true + +If you are using AGP 9.0.0 or a newer version, you don't need to set +`android.r8.optimizedResourceShrinking=true`. Optimized resource shrinking is +automatically applied when `isShrinkResources = true` is enabled in your build +configuration. + +## Verify and configure R8 optimization settings + +To enable R8 to use its [full optimization capabilities](https://developer.android.com/topic/performance/app-optimization/full-mode), remove the +following line from your project's `gradle.properties` file, if it exists: + + android.enableR8.fullMode=false # Remove this line from your codebase. + +Note that enabling app optimization makes stack traces difficult to understand, +especially if R8 renames class or method names. To get stack traces that +correctly correspond to your source code, see [Recover the original stack +trace](https://developer.android.com/topic/performance/app-optimization/test-and-troubleshoot-the-optimization#recover-original-stack-trace). + +If R8 is enabled, you should also [create Startup Profiles](https://developer.android.com/topic/performance/baselineprofiles/dex-layout-optimizations) for even better +startup performance. + +If you enable app optimization and it causes errors, here are some strategies to +fix them: + +- [Add keep rules](https://developer.android.com/topic/performance/app-optimization/add-keep-rules) to keep some code untouched. +- [Adopt optimizations incrementally](https://developer.android.com/topic/performance/app-optimization/adopt-optimizations-incrementally). +- Update your code to [use libraries that are better suited for + optimization](https://developer.android.com/topic/performance/app-optimization/choose-libraries-wisely). + +> [!CAUTION] +> **Caution:** Tools that replace or modify R8's output can negatively impact runtime performance. R8 is careful about including and testing many optimizations at the code level, in [DEX layout](https://developer.android.com/topic/performance/baselineprofiles/dex-layout-optimizations), and in correctly producing Baseline Profiles - other tools producing or modifying DEX files can break these optimizations, or otherwise regress performance. + +If you are interested in optimizing your build speed, see [Configure how R8 +runs](https://developer.android.com/build/r8-execution-profiles) for information on how to configure R8 based on your environment. + +## AGP and R8 version behavior changes + +The following table outlines the key features introduced in various versions of +the Android Gradle Plugin (AGP) and the R8 compiler. + +| AGP version | Features introduced | +|---|---| +| 9.1 | **Classes repackaged by default:** R8 repackages classes (moving them to the unnamed package, at the top level) to compact DEX further, eliminating the need to specify `-repackageclasses` option. For information about how this works and how to opt out, see [global options](https://developer.android.com/topic/performance/app-optimization/global-options#global-options). | +| 9.0 | **Optimized resource shrinking:** Enabled by default (controlled using `android.r8.optimizedResourceShrinking`). [Optimized resource shrinking](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization#optimize-resource-shrinking) helps integrate resource shrinking with the code optimization pipeline, leading to smaller, faster apps. By optimizing both code and resource references simultaneously, it identifies and removes resources referenced exclusively from unused code. This is a significant improvement over the previous separate optimization processes. This is especially useful for apps that share substantial resources and code across different form factor verticals, with measured improvements of over 50% in app size. The resulting size reduction leads to smaller downloads, faster installations, and a better user experience with faster startup, improved rendering, and fewer ANRs. **Library rule filtering:** Support for global options (for example, `-dontobfuscate`) in library consumer rules has been dropped, and apps will filter them out. For more information, see [Add global options](https://developer.android.com/topic/performance/app-optimization/global-options). **Kotlin null checks:** Optimized by default (controlled using `-processkotlinnullchecks`). This version also introduced significant improvements in build speed. For more information, see [Global options for additional optimization](https://developer.android.com/topic/performance/app-optimization/global-options#global-options). **Optimize specific packages:** You can use `packageScope` to optimize specific packages. This is in experimental support. For more information, see [Optimize specified packages with `packageScope`](https://developer.android.com/topic/performance/app-optimization/optimize-specified-packages). **Optimized by default:** Support for `getDefaultProguardFile("proguard-android.txt")` has been dropped, because it includes `-dontoptimize`, which should be avoided. Instead, use `"proguard-android-optimize.txt"`. If you need to globally disable optimization in your app, [add the flag manually to a proguard file](https://developer.android.com/topic/performance/app-optimization/global-options#global-options-2). | +| 8.12 | **Resource shrinking:** Initial support added (Off by default. Enable using `isShrinkResources`). Resource shrinking works in tandem with R8 to identify and remove unused resources effectively. **Logcat retracing:** Support for automatic retracing in the Android Studio [Logcat window](https://developer.android.com/studio/debug/logcat). | +| 8.6 | **Improved retracing:** Includes filename and line number retracing by default for all `minSdk` levels (previously required `minSdk` 26+ in version 8.2). Updating R8 helps ensure that stack traces from obfuscated builds are readily and clearly readable. This version improves how line numbers and source files are mapped, making it easier for tools like the Android Studio Logcat to automatically retrace crashes to the original source code. | +| 8.0 | **Full mode by default:** [R8 full mode](https://developer.android.com/topic/performance/app-optimization/full-mode) provides significantly more powerful optimization. It is enabled by default. You can opt out using `android.enableR8.fullMode=false`. | +| 7.0 | **Full mode available:** Introduced as an opt-in feature using `android.enableR8.fullMode=true`. Full mode applies more powerful optimizations by making stricter assumptions about how your code uses reflection and other dynamic features. While it reduces app size and improves performance, it might require additional keep rules to prevent necessary code from being stripped. | \ No newline at end of file diff --git a/.agents/skills/play/play-billing-library-version-upgrade/SKILL.md b/.agents/skills/play/play-billing-library-version-upgrade/SKILL.md new file mode 100644 index 00000000..ac34277e --- /dev/null +++ b/.agents/skills/play/play-billing-library-version-upgrade/SKILL.md @@ -0,0 +1,92 @@ +--- +name: play-billing-library-version-upgrade +description: Use this skill when upgrading or migrating an Android project from any + legacy Google Play Billing Library (PBL) version to the latest stable version of + PBL. +license: Complete terms in LICENSE.txt +metadata: + author: Google LLC + keywords: + - android + - play billing + - play billing library + - pbl + - upgrade + - migration + - deprecation + - google play +--- + +## Phase 0: Intent Message + +**Reporting Action**: Before proceeding, immediately tell the user: "I will +upgrade Play Billing Library to the latest version." + +## Phase 1: Discovery \& Situational Awareness + +1. **Primary Check (Build Version)** : Locate the project's billing dependency (e.g., `com.android.billingclient:billing`) in `build.gradle`, `build.gradle.kts`, or `libs.versions.toml`. +2. **Initial Compilation Test**: Attempt to sync and build the project immediately. +3. **Fallback Discovery (Effective Version)** : + - **Trigger**: Only if the build fails immediately, scan the source code for deprecated artifacts. + - **Logic** : The presence of deprecated APIs indicates the **"Effective + Version"** ---defined as the version where those specific APIs were **last + available**, not when they were introduced. + - **Example** : If `SkuDetails` is present, treat the baseline as **PBL v7** or earlier (regardless of the version string in `build.gradle`). +4. **Identify Target \& Path:** Access the version tool or release notes to find the latest stable version and calculate a \[Direct/Stepped\] migration path based on the **Effective Version** baseline. + +- **Calculate Migration Path** : + - If the **Effective Version** is within 2 major versions of the target: Plan a **Direct Migration**. + - If it is more than 2 major versions behind: Plan a **Stepped + Migration**. Migrate by two major versions at a time (e.g., v4 -\> v6 -\> v8) until you are within two versions of the target. +- **Reporting Action**: Before proceeding, tell the user: "I've detected you are effectively on PBL \[Current\] and the latest is \[Target\]. I am planning a \[direct/stepped\] migration path." + +## Phase 2: Contextual Document Mapping \& Planning + +For every major version jump identified in your path, you **MUST** synthesize +instructions from: + +- **[Migration Guide](https://developer.android.com/google/play/billing/migrate-gpblv%5BX%5D)** (where `[X]` is the target major version). +- **Release Highlights** : The "Deprecations" and "Breaking Changes" sections of the relevant [Release Notes](references/android/google/play/billing/release-notes.md). +- **Developer Documentation**: Consult your knowledge of the Google Play Billing documentation regarding the relevant features used in this app (e.g., Subscriptions, One-Time Products). +- **Develop the Plan**: Identify every specific code change required (API removals, class replacements, logic shifts) and print this out as a checklist. + +## Phase 3: Instructions for Execution + +*Reporting Action: For each of the following steps, give a brief explanation of +what you will be doing prior to execution, and a brief summary of what you +accomplished afterwards.* + +### Step 1: SDK \& Environment Alignment + +- **Action** : Update `build.gradle` to meet SDK requirements (e.g., "PBL 8 requires `compileSdk` 35"). +- **Gradle Version**: Verify if the new library requires a newer Android Gradle Plugin (AGP) or Kotlin version. + +### Step 2: Intent-based Refactoring + +Analyze the intent of the existing code rather than performing purely textual +string replacement. + +- **Action** : You **MUST** follow all deprecation instructions and refactor patterns from **both** the [references/migration-logic.md](references/migration-logic.md) section, the official migration guides, and the general documentation pages identified in Phase 2. +- **Verification** : Verify you are doing **all steps from all documentation** and then making sure you follow the specific directions from the checklist in the references. + +### Step 3: Sequential Verification (Only applicable for Stepped Migrations) + +1. **Upgrade** to the first major intermediate version in your path. +2. **Run `./gradlew assembleDebug`** to verify no intermediate breaking changes were missed. +3. **Repeat** until you reach the final target version. + +### Step 4: Final Validation Checklist + +1. **Smart Checklist Verification:** +2. Open [references/version-checklist.md](references/version-checklist.md) and locate the **Smart Version-Specific Checklist**. +3. **Action**: For every version between your \[Detected Effective Version\] and \[Detected New Version\], verify that every item has been addressed in the code using "Find in Files" or structural analysis. +4. **Tests** : Run all unit and implementation tests (`./gradlew test`). +5. **Clean Build** : Verify the project completes a full clean build: `./gradlew clean assembleDebug`. Then, run `./gradlew sync` and `./gradlew build` so that the user can immediately test the new version manually. + +## Final Report + +Explain the "Why" to the developer: + +- "I updated your SDK to \[Version\] because PBL \[Version\] requires it for \[Reason from docs\]." +- "I removed your custom `retryConnection()` logic because it is now handled natively by the library using `enableAutoServiceReconnection()`." +- "Successfully upgraded from PBL \[Old\] to PBL \[New\] and verified with unit tests. Based on an analysis of features in the latest library and this application's current feature set, I suggest exploring \[New Feature\] (e.g., Prepaid Plans or Installments) from the latest release because it is now available but not yet implemented." \ No newline at end of file diff --git a/.agents/skills/play/play-billing-library-version-upgrade/references/android/google/play/billing/release-notes.md b/.agents/skills/play/play-billing-library-version-upgrade/references/android/google/play/billing/release-notes.md new file mode 100644 index 00000000..4a1dd9a1 --- /dev/null +++ b/.agents/skills/play/play-billing-library-version-upgrade/references/android/google/play/billing/release-notes.md @@ -0,0 +1,1171 @@ +This document contains release notes for the Google Play Billing Library. + +## Google Play Billing Library 8.3.0 Release (2025-12-23) + +Version 8.3.0 of the Google Play Billing Library and Kotlin extensions are now available. + +### Summary of changes + +- New APIs for [external payments](https://developer.android.com/google/play/billing/externalpaymentlinks): + + - Added classes to support external payments flow: + - [`BillingProgram.EXTERNAL_PAYMENTS`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingProgram#EXTERNAL_PAYMENTS) + - [`EnableBillingProgramParams`](https://developer.android.com/reference/com/android/billingclient/api/EnableBillingProgramParams) + - [`DeveloperBillingOptionParams`](https://developer.android.com/reference/com/android/billingclient/api/DeveloperBillingOptionParams) + - [`DeveloperProvidedBillingDetails`](https://developer.android.com/reference/com/android/billingclient/api/DeveloperProvidedBillingDetails) + - [`DeveloperProvidedBillingListener`](https://developer.android.com/reference/com/android/billingclient/api/DeveloperProvidedBillingListener) + - Added [`enableBillingProgram(EnableBillingProgramParams)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder#enableBillingProgram(com.android.billingclient.api.EnableBillingProgramParams)) to enable external payments. + - Added [`BillingFlowParams.Builder.enableDeveloperBillingOption`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#enableDeveloperBillingOption(com.android.billingclient.api.DeveloperBillingOptionParams)) to launch the external payments flow. + +## Google Play Billing Library 8.2.1 Release (2025-12-15) + +Version 8.2.1 of the Google Play Billing Library and Kotlin extensions are now +available. + +### Bug fixes + +- Fixed a bug in [`isBillingProgramAvailableAsync`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#isBillingProgramAvailableAsync(int,com.android.billingclient.api.BillingProgramAvailabilityListener)) and [`createBillingProgramReportingDetailsAsync`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#createBillingProgramReportingDetailsAsync(com.android.billingclient.api.BillingProgramReportingDetailsParams,com.android.billingclient.api.BillingProgramReportingDetailsListener)). Update to version 8.2.1 to use these APIs introduced in 8.2.0. + +## Google Play Billing Library 8.2.0 Release (2025-12-09) + +Version 8.2.0 of the Google Play Billing Library and Kotlin extensions are now available. + +### Summary of changes + +- New APIs for [external content links](https://developer.android.com/google/play/billing/externalcontentlinks) and [external offers](https://developer.android.com/google/play/billing/external): + + - Added [`enableBillingProgram`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder#enableBillingProgram(int)) to setup the `BillingClient` for these programs. + - Added [`isBillingProgramAvailableAsync`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#isBillingProgramAvailableAsync(int,com.android.billingclient.api.BillingProgramAvailabilityListener)) to determine user eligibility. + - Added [`createBillingProgramReportingDetailsAsync`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#createBillingProgramReportingDetailsAsync(com.android.billingclient.api.BillingProgramReportingDetailsParams,com.android.billingclient.api.BillingProgramReportingDetailsListener)) to create the external transaction token that must be used for reporting. + - Added [`launchExternalLink`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#launchExternalLink(android.app.Activity,com.android.billingclient.api.LaunchExternalLinkParams,com.android.billingclient.api.LaunchExternalLinkResponseListener)) to initiate the external link to a digital content offer or an app download. +- Changes to the [external offers program](https://developer.android.com/google/play/billing/external): + + - There are policy changes for the external offers program. See [program changes](https://support.google.com/googleplay/android-developer/answer/16505463) for details. To understand how to launch external offer flows with the new APIs, see the [integration guide](https://developer.android.com/google/play/billing/external/integration). + - Deprecated the [`BillingClient.Builder.enableExternalOffer`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder#enableExternalOffer()) API. + - Deprecated the [`isExternalOfferAvailableAsync`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#isExternalOfferAvailableAsync(com.android.billingclient.api.ExternalOfferAvailabilityListener)) API. + - Deprecated the [`createExternalOfferReportingDetailsAsync`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#createExternalOfferReportingDetailsAsync(com.android.billingclient.api.ExternalOfferReportingDetailsListener)) API. + - Deprecated the [`showExternalOfferInformationDialog`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#showExternalOfferInformationDialog(android.app.Activity,com.android.billingclient.api.ExternalOfferInformationDialogListener)) API. + +## Google Play Billing Library 8.1.0 Release (2025-11-06) + +Version 8.1.0 of the Google Play Billing Library and Kotlin extensions are now available. + +### Summary of changes + +- Suspended subscriptions + + A new parameter has been added to the + [`BillingClient.queryPurchasesAsync()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#queryPurchasesAsync(com.android.billingclient.api.QueryPurchasesParams,com.android.billingclient.api.PurchasesResponseListener)) + method to include suspended subscriptions when querying for subscriptions. + Suspended subscriptions are still attributed to the user, but are not active, + either because the user paused the subscription or their renewal payment method + was declined. + + The [`Purchase`](https://developer.android.com/reference/com/android/billingclient/api/Purchase) object returned in the listener will return `isSuspended() = true` + for any suspended subscriptions. In this case, you shouldn't grant access + to the purchased subscription, and instead guide the user to the + [subscriptions center](https://play.google.com/store/account/subscriptions) + where the user can manage their payment methods + or pause state to re-activate their subscription. +- Updates to [subscriptions](https://developer.android.com/google/play/billing/subscriptions): + + - The [`BillingFlowParams.ProductDetailsParams`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.ProductDetailsParams) + object now has the `setSubscriptionProductReplacementParams()` method + in which you can specify product level replacement information. + + - The `SubscriptionProductReplacementParams` object has + two setter methods: + + - `setOldProductId`: The old product that needs to be replaced by the product in current [`ProductDetails`](https://developer.android.com/reference/com/android/billingclient/api/ProductDetails). + - `setReplacementMode`: This is the item level replacement mode. The modes are essentially the same as SubscriptionUpdateParams, but the value mapping has been updated. A new replacement mode `KEEP_EXISTING` is introduced that lets you keep the existing payment schedule unchanged for an item. + - [SubscriptionUpdateParams setSubscriptionReplacementMode](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.SubscriptionUpdateParams.Builder#setSubscriptionReplacementMode(int)) will be + deprecated. You should use `SubscriptionProductReplacementParams.setReplacementMode` + instead. + +- Updated `minSdkVersion` to 23. + +- Enabled [pre-order APIs for one-time products](https://developer.android.com/google/play/billing/one-time-product-multi-purchase-options-offers#pre-order) + + The `ProductDetails.oneTimePurchaseOfferDetails.getPreorderDetails()` API + that gets the pre-order details is now available for use. +- Google Play Billing Library now supports [Kotlin version 2.2.0](https://kotlinlang.org/docs/whatsnew22.html). + +## Google Play Billing Library 8.0.0 Release (2025-06-30) + +Version 8.0.0 of the Google Play Billing Library and Kotlin extensions are now +available. + +### Summary of changes + +- *In-app items* will now be referred to as *one-time products*. + +- Multiple purchase options and offers for one-time products. + + You can now have multiple purchase options and offers for your one-time + products. This provides you flexibility in how you sell your products and + reduces the complexity of managing them. +- Improved the [`queryProductDetailsAsync()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#queryProductDetailsAsync(com.android.billingclient.api.QueryProductDetailsParams,com.android.billingclient.api.ProductDetailsResponseListener)) method. + + Prior to PBL 8.0.0, the [`queryProductDetailsAsync()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#queryProductDetailsAsync(com.android.billingclient.api.QueryProductDetailsParams,com.android.billingclient.api.ProductDetailsResponseListener)) method didn't + return products that couldn't be fetched. This could be due to reasons such + as the product is not found or no offers are available to the user. With PBL + 8.0.0, unfetched products are returned with a new product-level status code + that provides information about unfetched products. Note that there is a + change in the signature of the + [`ProductDetailsResponseListener.onProductDetailsResponse()`](https://developer.android.com/google/play/billing/integrate#automatic-service-reconnection) which + requires changes in your app. For more information, see [process the + result](https://developer.android.com/google/play/billing/integrate#process-the-result). +- Automatic service reconnection. + + The new `BillingClient.Builder.enableAutoServiceReconnection()` builder + parameter lets developers opt-in to automatic service reconnection, which + simplifies connection management by handling reconnections to + the Play Billing Service automatically and eliminating the need to manually + call `startConnection()` in the event of a service disconnection. + For more information, see [Automatically Re-establish a Connection](https://developer.android.com/google/play/billing/integrate#automatic-service-reconnection). +- Sub-response codes for the [`launchBillingFlow()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#launchBillingFlow(android.app.Activity,com.android.billingclient.api.BillingFlowParams)) method. + + The [BillingResult](https://developer.android.com/reference/com/android/billingclient/api/BillingResult) returned from [`launchBillingFlow()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#launchBillingFlow(android.app.Activity,com.android.billingclient.api.BillingFlowParams)) will now + include a sub-response code field. This field will only be populated in some + cases to provide a more specific reason for the failure. The sub-response + field can have the following values: + - `PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS` - Returned when the user's funds are less than the price of the item they are attempting to purchase. + - `USER_INELIGIBLE` - Returned when the user doesn't meet the configured eligibility requirements for a subscription offer. + - `NO_APPLICABLE_SUB_RESPONSE_CODE` - The default value, returned when no other sub-response code is applicable. +- Removed the `queryPurchaseHistory()` method. + + The `queryPurchaseHistory()` method that was previously marked as + deprecated has now been removed. See [Query Purchase History](https://developer.android.com/google/play/billing/query-purchase-history) for + details on what alternative APIs to use instead. +- Removed the `querySkuDetailsAsync()` method. + + The `querySkuDetailsAsync()` method that was previously marked as deprecated + has now been removed. You should use [queryProductDetailsAsync](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#queryProductDetailsAsync(com.android.billingclient.api.QueryProductDetailsParams,com.android.billingclient.api.ProductDetailsResponseListener)) instead. +- Removed the `BillingClient.Builder.enablePendingPurchases()` method. + + The `enablePendingPurchases()` method with no parameters that was previously + marked as deprecated has now been removed. You should use + `enablePendingPurchases(PendingPurchaseParams params)` instead. Note that + the deprecated `enablePendingPurchases()` is functionally equivalent to + `enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build())`. +- Removed the overloaded `queryPurchasesAsync()` method that takes a [skuType](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.SkuType). + + The `queryPurchasesAsync(String skuType, PurchasesResponseListener + listener)` method that was previously marked as + deprecated has now been removed. Alternately, use + [`queryPurchasesAsync(QueryPurchasesParams queryPurchasesParams, + PurchasesResponseListener listener)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#queryPurchasesAsync(com.android.billingclient.api.QueryPurchasesParams,com.android.billingclient.api.PurchasesResponseListener)). + +## Google Play Billing Library 7.1.1 Release (2024-10-03) + +Version 7.1.1 of the Google Play Billing Library and Kotlin extensions are now +available. + +### Bug fixes + +- Fixed a bug in Play Billing Library 7.1.0 related to [testing + `BillingResult` response codes](https://developer.android.com/google/play/billing/test-response-codes). + +## Google Play Billing Library 7.1.0 Release (2024-09-19) + +Version 7.1.0 of the Google Play Billing Library and Kotlin extensions are now +available. + +### Summary of changes + +- Improved thread safety related to connection status and management. +- Introduced partial changes for testing [`BillingResult`](https://developer.android.com/reference/com/android/billingclient/api/BillingResult) response codes which is fully released in Play Billing Library 7.1.1. To test your integration using this feature, you'll need to upgrade to Play Billing Library 7.1.1. A bug exists that will only impact applications with [billing overrides testing enabled](https://developer.android.com/google/play/billing/test-response-codes#enable-billing-overrides-testing) and does not affect regular usage. For more information, see [Test `BillingResult` + response codes](https://developer.android.com/google/play/billing/test-response-codes). + +## Google Play Billing Library 7.0.0 Release (2024-05-14) + +Version 7.0.0 of the Google Play Billing Library and Kotlin extensions are now +available. + +### Summary of changes + +- Added APIs to support installment subscriptions. + + - Added [`ProductDetails.InstallmentPlanDetails`](https://developer.android.com/reference/com/android/billingclient/api/ProductDetails.InstallmentPlanDetails) for installment base plans that users are eligible to purchase. This API helps your app identify the installment plan and its commitment setup to provide related information to the user. To learn more, see our [subscription installments guide](https://developer.android.com/google/play/billing/subscriptions#installments). +- Added [`PendingPurchasesParams`](https://developer.android.com/reference/com/android/billingclient/api/PendingPurchasesParams) and + [`BillingClient.Builder.enablePendingPurchases(PendingPurchaseParams)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder#enablePendingPurchases(PendingPurchaseParams)) + to replace + [`BillingClient.Builder.enablePendingPurchases()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder#enablePendingPurchases()), + which has been deprecated in this release. + + - The deprecated `enablePendingPurchases()` is functionally equivalent to `enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build())`. +- Added APIs to support pending transactions for subscription prepaid plans: + + - Use [`PendingPurchasesParams.Builder.enablePrepaidPlans()`](https://developer.android.com/reference/com/android/billingclient/api/PendingPurchasesParams.Builder#enablePrepaidPlans()) along with [`BillingClient.Builder.enablePendingPurchases(PendingPurchaseParams)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder#enablePendingPurchases(PendingPurchaseParams)) to enable pending transactions for subscription prepaid plans. When adding support, be sure that your app also correctly manages subscription lifecycles. To learn more see our [pending purchases + guide](https://developer.android.com/google/play/billing/subscriptions#pending). + - Added [`Purchase.PendingPurchaseUpdate`](https://developer.android.com/reference/com/android/billingclient/api/Purchase.PendingPurchaseUpdate) and [`Purchase.getPendingPurchaseUpdate()`](https://developer.android.com/reference/com/android/billingclient/api/Purchase.getPendingPurchaseUpdate()) for retrieving the pending top-up or upgrade or downgrade to an existing subscription. +- Removed + [`BillingClient.Builder.enableAlternativeBilling()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder#enableAlternativeBilling(com.android.billingclient.api.AlternativeBillingListener)), + [`AlternativeBillingListener`](https://developer.android.com/reference/com/android/billingclient/api/AlternativeBillingListener), and + [`AlternativeChoiceDetails`](https://developer.android.com/reference/com/android/billingclient/api/AlternativeChoiceDetails). + + - Developers should use [`BillingClient.Builder.enableUserChoiceBilling()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder#enableUserChoiceBilling(com.android.billingclient.api.UserChoiceBillingListener)) with [`UserChoiceBillingListener`](https://developer.android.com/reference/com/android/billingclient/api/UserChoiceBillingListener) and [`UserChoiceDetails`](https://developer.android.com/reference/com/android/billingclient/api/UserChoiceDetails) in the listener callback instead. +- Removed [`BillingFlowParams.ProrationMode`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.ProrationMode), + [`BillingFlowParams.SubscriptionUpdateParams.Builder.setReplaceProrationMode()`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.SubscriptionUpdateParams.Builder#setReplaceProrationMode(int)), + and + [`BillingFlowParams.SubscriptionUpdateParams.Builder.setReplaceSkusProrationMode()`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.SubscriptionUpdateParams.Builder#setReplaceSkusProrationMode(int)). + + - Developers should use [`BillingFlowParams.SubscriptionUpdateParams.ReplacementMode`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.SubscriptionUpdateParams.ReplacementMode) with [`BillingFlowParams.SubscriptionUpdateParams.Builder#setSubscriptionReplacementMode(int)`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.SubscriptionUpdateParams.Builder#setSubscriptionReplacementMode(int)) instead. +- Removed + [`BillingFlowParams.SubscriptionUpdateParams.Builder#setOldSkuPurchaseToken()`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.SubscriptionUpdateParams.Builder#setOldSkuPurchaseToken(java.lang.String)). + + - Developers should use [`BillingFlowParams.SubscriptionUpdateParams.Builder#setOldPurchaseToken(java.lang.String)`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.SubscriptionUpdateParams.Builder#setOldPurchaseToken(java.lang.String)) instead. +- [`BillingClient.queryPurchaseHistoryAsync()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#queryPurchaseHistoryAsync(com.android.billingclient.api.QueryPurchaseHistoryParams,com.android.billingclient.api.PurchaseHistoryResponseListener)) + has been deprecated and will be removed in a future release. Developers + should use the following alternatives instead: + + - Acknowledged and pending purchases: Use [`BillingClient.queryPurchasesAsync()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#queryPurchasesAsync(com.android.billingclient.api.QueryPurchasesParams,%20com.android.billingclient.api.PurchasesResponseListener)) to fetch the active purchases. + - Consumed purchases: Developers should keep track of consumed purchases on their own servers. + - Canceled purchases: Use the [voided-purchases](https://developers.google.com/android-publisher/voided-purchases) developer API. + - For more details, see [Query Purchase History](https://developer.android.com/google/play/billing/query-purchase-history) +- [`BillingFlowParams.ProductDetailsParams.setOfferToken()`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.ProductDetailsParams.Builder#setOfferToken(java.lang.String)) + now throws an exception when developers specify an empty `offerToken`. + +- Updated `minSdkVersion` to 21 and `targetSdkVersion` to 34. + +## Google Play Billing Library 6.2.1 Release (2024-04-16) + +Version 6.2.1 of the Google Play Billing Library and Kotlin extensions are now +available. + +### Summary of changes + +- Fixed a bug in [`BillingClient.showAlternativeBillingOnlyInformationDialog()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#showAlternativeBillingOnlyInformationDialog(android.app.Activity,%20com.android.billingclient.api.AlternativeBillingOnlyInformationDialogListener)) where the [`AlternativeBillingOnlyInformationDialogListener`](https://developer.android.com/reference/com/android/billingclient/api/AlternativeBillingOnlyInformationDialogListener) may not be called in certain cases when the dialog completes. + +## Google Play Billing Library 6.2.0 Release (2024-03-06) + +Version 6.2.0 of the Google Play Billing Library and Kotlin extensions are now +available. + +### Summary of changes + +- Added APIs to support [external offers](https://developer.android.com/google/play/billing/external) + - Added [`BillingClient.Builder.enableExternalOffer()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder#enableExternalOffer()) to enable the ability to provide external offers. + - Added [`BillingClient.isExternalOfferAvailableAsync()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#isExternalOfferAvailableAsync(com.android.billingclient.api.ExternalOfferAvailabilityListener)) to check the availability of providing external offers functionality. + - Added [`BillingClient.showExternalOfferInformationDialog()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#showExternalOfferInformationDialog(android.app.Activity,%20com.android.billingclient.api.ExternalOfferInformationDialogListener)) to show an information dialog to users before leading users outside the app. + - Added [`BillingClient.createExternalOfferReportingDetailsAsync()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#createExternalOfferReportingDetailsAsync(com.android.billingclient.api.ExternalOfferReportingDetailsListener)) to create a payload required to report transactions made through external offers. + +## Google Play Billing Library 6.1.0 Release (2023-11-14) + +Version 6.1.0 of the Google Play Billing Library and Kotlin extensions are now +available. + +### Summary of changes + +- Added APIs to support [alternative billing only (i.e. without user choice)](https://developer.android.com/google/play/billing/alternative) + - Added [`BillingClient.Builder.enableAlternativeBillingOnly()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder#enableAlternativeBillingOnly()) to functionally enable the ability to offer alternative billing only. + - Added [`BillingClient.isAlternativeBillingOnlyAvailableAsync()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#isAlternativeBillingOnlyAvailableAsync(com.android.billingclient.api.AlternativeBillingOnlyAvailabilityListener)) to check the availability of offering alternative billing only. + - Added [`BillingClient.showAlternativeBillingOnlyInformationDialog()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#showAlternativeBillingOnlyInformationDialog(android.app.Activity,%20com.android.billingclient.api.AlternativeBillingOnlyInformationDialogListener)) to show an information dialog to inform users when alternative billing only is being used. + - Added [`BillingClient.createAlternativeBillingOnlyReportingDetailsAsync()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#createAlternativeBillingOnlyReportingDetailsAsync(com.android.billingclient.api.AlternativeBillingOnlyReportingDetailsListener)) to create a payload required to report transactions made through alternative billing only. +- Updated the user choice billing APIs + - Added [`UserChoiceBillingListener`](https://developer.android.com/reference/com/android/billingclient/api/UserChoiceBillingListener) to replace [AlternativeBillingListener](https://developer.android.com/reference/com/android/billingclient/api/AlternativeBillingListener) which has been marked as deprecated. + - Added [`UserChoiceDetails`](https://developer.android.com/reference/com/android/billingclient/api/UserChoiceDetails) to replace [`AlternativeChoiceDetails`](https://developer.android.com/reference/com/android/billingclient/api/AlternativeChoiceDetails) which has been marked as deprecated. + - Added [`BillingClient.Builder.enableUserChoiceBilling()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder#enableUserChoiceBilling(com.android.billingclient.api.UserChoiceBillingListener)) to replace [`BillingClient.Builder.enableAlternativeBilling()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder#enableAlternativeBilling(com.android.billingclient.api.AlternativeBillingListener)) which has been marked as deprecated. +- Added [`BillingClient.getBillingConfigAsync()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#getBillingConfigAsync(com.android.billingclient.api.GetBillingConfigParams,%20com.android.billingclient.api.BillingConfigResponseListener)) to retrieve Google Play country. + +## Google Play Billing Library 6.0.1 Release (2023-06-22) + +Version 6.0.1 of the Google Play Billing Library and Kotlin extensions are now +available. + +### Summary of changes + +Update Play Billing Library to be compatible with Android 14. + +## Google Play Billing Library 6.0 Release (2023-05-10) + +Version 6.0.0 of the Google Play Billing Library and Kotlin extensions are now +available. + +### Summary of changes + +- Added new [`ReplacementMode`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.SubscriptionUpdateParams.ReplacementMode) + enum to replace + [`ProrationMode`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.ProrationMode). + + Note that `ProrationMode` is still available for backward compatibility + reasons. +- Removed order ID for [`PENDING`](https://developer.android.com/reference/com/android/billingclient/api/Purchase.PurchaseState#PENDING) + purchases. + + Previously, the order ID would always be created even if the purchase was + pending. Starting with version 6.0.0, an order ID will not be created for + pending purchases, and for these purchases, the order ID will be populated + after the purchase is moved to the + [`PURCHASED`](https://developer.android.com/reference/com/android/billingclient/api/Purchase.PurchaseState#PURCHASED) + state. +- Removed `queryPurchases` and `launchPriceConfirmationFlow` methods. + + The `queryPurchases` and `launchPriceConfirmationFlow` methods that have + previously been marked as deprecated have now been removed in Play Billing + Library 6.0.0. Developers should use + [`queryPurchasesAsync`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#queryPurchasesAsync(com.android.billingclient.api.QueryPurchasesParams,%20com.android.billingclient.api.PurchasesResponseListener)) + instead of `queryPurchases`. For `launchPriceConfirmationFlow` alternatives, + see [Price changes](https://developer.android.com/google/play/billing/price-changes). +- Added new network error response code. + + A new network error response code, + [`NETWORK_ERROR`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingResponseCode#NETWORK_ERROR), + has been added starting with PBL version 6.0.0. This code is returned when + an error occurs due to a network connection issue. These network connection + errors were previously reported as `SERVICE_UNAVAILABLE`. +- Updated [`SERVICE_UNAVAILABLE`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingResponseCode#SERVICE_UNAVAILABLE) + and + [`SERVICE_TIMEOUT`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingResponseCode#SERVICE_TIMEOUT). + + Starting with PBL version 6.0.0, errors due to timeout in processing will be + returned as `SERVICE_UNAVAILABLE` instead of the current `SERVICE_TIMEOUT`. + + The behavior does not change in earlier versions of PBL. +- Removed + [`SERVICE_TIMEOUT`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingResponseCode#SERVICE_TIMEOUT). + + Starting with PBL version 6.0.0, `SERVICE_TIMEOUT` will no longer be + returned. Previous versions of PBL will still return this code. +- Added additional logging. + + The Play Billing Library 6 release includes additional logging, which + provides insight into API usage (such as success and failure) and service + connection issues. This information will be used to improve the performance + of the Play Billing Library and provide better support for errors. + +## Google Play Billing Library 5.2.1 Release (2023-06-22) + +Version 5.2.1 of the Google Play Billing Library and Kotlin extensions are now +available. + +### Summary of changes + +Update Play Billing Library to be compatible with Android 14. + +## Google Play Billing Library 5.2 Release (2023-04-06) + +Version 5.2.0 of the Google Play Billing Library and Kotlin extensions are now +available. + +### Summary of changes + +- Added classes to support alternative billing flows on mobile/tablet for users in South Korea: + - [`AlternativeBillingListener`](https://developer.android.com/reference/com/android/billingclient/api/AlternativeBillingListener) + - [`AlternativeChoiceDetails`](https://developer.android.com/reference/com/android/billingclient/api/AlternativeChoiceDetails) + - [`AlternativeChoiceDetails.Product`](https://developer.android.com/reference/com/android/billingclient/api/AlternativeChoiceDetails.Product) +- Added [`BillingFlowParams.SubscriptionUpdateParams.Builder.setOriginalExternalTransactionId()`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.SubscriptionUpdateParams.Builder#setOriginalExternalTransactionId(java.lang.String)) method to specify the external transaction id of the originating subscription. +- Added [`BillingClient.Builder.enableAlternativeBilling()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder#enableAlternativeBilling(com.android.billingclient.api.AlternativeBillingListener)) method to allow users in South Korea to select an alternative billing option. + +## Google Play Billing Library 5.1 Release (2022-10-31) + +Version 5.1.0 of the Google Play Billing Library and Kotlin extensions are now +available. + +This version contains the following changes. + +### Summary of changes + +- Added [`ProductDetails.SubscriptionOfferDetails.getOfferId()`](https://developer.android.com/reference/com/android/billingclient/api/ProductDetails.SubscriptionOfferDetails#getOfferId()) method to retrieve the offer ID. +- Added [`ProductDetails.SubscriptionOfferDetails.getBasePlanId()`](https://developer.android.com/reference/com/android/billingclient/api/ProductDetails.SubscriptionOfferDetails#getBasePlanId()) method to retrieve the base plan ID. +- Updated the `targetSdkVersion` to 31. + +## Google Play Billing Library 5.0 Release (2022-05-11) + +Version 5.0.0 of the Google Play Billing Library and Kotlin extensions are now +available. + +This version contains the following changes. + +### Summary of changes + +- Introduced a new model for subscriptions, including new entities that enable you to create multiple offers for a single subscription product. For more information, see the [migration guide](https://developer.android.com/google/play/billing/migrate-gpblv5). +- Added [`BillingClient.queryProductDetailsAsync()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#queryProductDetailsAsync(com.android.billingclient.api.QueryProductDetailsParams,%20com.android.billingclient.api.ProductDetailsResponseListener)) to replace `BillingClient.querySkuDetailsAsync()`. +- Added `setIsOfferPersonalized()` method for EU personalized pricing disclosure requirements. To learn more about how to use this method, see [Indicate a personalized + price](https://developer.android.com/google/play/billing/integrate#personalized-price). +- Removed `queryPurchases()`, which was previously deprecated and replaced by queryPurchasesAsync introduced in Google Play Billing Library 4.0.0. +- `launchPriceChangeFlow` has been deprecated and will be removed in a future release. To learn more about alternatives, see [Launch a price change + confirmation flow](https://developer.android.com/google/play/billing/subscriptions#price-change-launch). +- Removed [`setVrPurchaseFlow()`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setVrPurchaseFlow(boolean)), which was previously used when instantiating a purchase flow. In previous versions, this method redirected the user to complete the purchase on their Android-powered device. Once you remove this method, users will complete the purchase through the standard purchase flow. + +## Google Play Billing Library 4.1 release (2022-02-23) + +Version 4.1.0 of the Google Play Billing Library and Kotlin extensions are now +available. + +This version contains the following changes. + +### Summary of changes + +- Added [`BillingClient.showInAppMessages()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#showInAppMessages(android.app.Activity,%20com.android.billingclient.api.InAppMessageParams,%20com.android.billingclient.api.InAppMessageResponseListener)) to help with handling subscription payment declines. To learn more about how to use in-app messaging for handling subscriptions payment declines, see [Handling payment + declines](https://developer.android.com/google/play/billing/subscriptions#payment-declines). + +## Google Play Billing Library 4.0 Release (2021-05-18) + +Version 4.0.0 of the Google Play Billing Library and Kotlin extensions are now +available. + +### Summary of changes + +- Added + [`BillingClient.queryPurchasesAsync()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#queryPurchasesAsync(java.lang.String,%20com.android.billingclient.api.PurchasesResponseListener)) + to replace + [`BillingClient.queryPurchases()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#queryPurchases(java.lang.String)) + which will be removed in a future release. + +- Added new subscription replacement mode + [`IMMEDIATE_AND_CHARGE_FULL_PRICE`](https://developer.android.com/google/play/billing/subscriptions#change). + +- Added + [`BillingClient.getConnectionState()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#getConnectionState()) + method to retrieve the Play Billing Library's connection state. + +- Updated Javadoc and implementation to indicate which thread a method can be + called on and which thread results are posted. + +- Added + [`BillingFlowParams.Builder.setSubscriptionUpdateParams()`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setSubscriptionUpdateParams(com.android.billingclient.api.BillingFlowParams.SubscriptionUpdateParams)) + as a new way to initiate subscription updates. This replaces + `BillingFlowParams#getReplaceSkusProrationMode`, + `BillingFlowParams#getOldSkuPurchaseToken`, `BillingFlowParams#getOldSku`, + `BillingFlowParams.Builder#setReplaceSkusProrationMode`, + `BillingFlowParams.Builder#setOldSku` which have been removed. + +- Added + [`Purchase.getQuantity()`](https://developer.android.com/reference/com/android/billingclient/api/Purchase#getQuantity()) + and + [`PurchaseHistoryRecord.getQuantity()`](https://developer.android.com/reference/com/android/billingclient/api/PurchaseHistoryRecord#getQuantity()). + +- Added + [`Purchase#getSkus()`](https://developer.android.com/reference/com/android/billingclient/api/Purchase#getSkus()) + and + [`PurchaseHistoryRecord#getSkus()`](https://developer.android.com/reference/com/android/billingclient/api/PurchaseHistoryRecord#getSkus()). + These replace `Purchase#getSku` and `PurchaseHistoryRecord#getSku` which + have been removed. + +- Removed `BillingFlowParams#getSku`, `BillingFlowParams#getSkuDetails` and + `BillingFlowParams#getSkuType`. + +## Google Play Billing Library 3.0.3 Release (2021-03-12) + +Version 3.0.3 of the Google Play Billing Library, Kotlin extension, and Unity +plugin are now available. + +### Java and Kotlin Bug fixes + +- Fix memory leak when [`endConnection()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#endConnection()) is called. +- Fix issue when the Google Play Billing Library is used by apps which use the single task launch mode. A [`onPurchasesUpdated()`](https://developer.android.com/reference/com/android/billingclient/api/PurchasesUpdatedListener#onpurchasesupdated) callback will be triggered when an app is resumed from the Android launcher and the billing dialog was visible prior to being suspended. + +### Unity Bug fixes + +- Update to Java version 3.0.3 to fix memory leak and resolve issue preventing purchases when an app is resumed from the Android launcher and the billing dialog was visible prior to being suspended. + +## Google Play Billing Library 3.0.2 Release (2020-11-24) + +Version 3.0.2 of the Google Play Billing Library and Kotlin extension are now +available. + +### Bug fixes + +- Fixed a bug in the Kotlin extension where the coroutine fails with error "Already resumed". +- Fixed unresolved references when the Kotlin extension is used with the kotlinx.coroutines library version 1.4+. + +## Google Play Billing Library 3.0.1 Release (2020-09-30) + +Version 3.0.1 of the Google Play Billing Library and Kotlin extension are now +available. + +### Bug fixes + +- Fixed a bug where if the app was killed and restored during the billing flow, `PurchasesUpdatedListener` may not be called with the purchase result. + +## Google Play Billing Library 3.0 Release (2020-06-08) + +Version 3.0.0 of the Google Play Billing Library, Kotlin extension, and Unity +plugin are now available. + +### Summary of changes + +- Removed rewarded SKU support. +- Removed the `ChildDirected` and `UnderAgeOfConsent` parameters. +- Removed deprecated developer payload methods. +- Removed deprecated methods `BillingFlowParams.setAccountId()` and `BillingFlowParams.setDeveloperId()`. +- Removed deprecated methods `BillingFlowParams.setOldSkus(String oldSku)` and `BillingFlowParams.addOldSku(String oldSku)`. +- Added nullability annotations. + +### Bug fixes + +- [`SkuDetails.getIntroductoryPriceCycles()`](https://developer.android.com/reference/com/android/billingclient/api/SkuDetails#getIntroductoryPriceCycles()) now returns `int` instead of `String`. +- Fixed a bug where the billing flow would be treated as having extra params even if no extra params were set. + +## Google Play Billing Library 2.2.1 Release (2020-05-20) + +Version 2.2.1 of the Google Play Billing library is now available. + +### Bug fixes + +- Updated the default version of the Java Play Billing library that the Kotlin extension depends on. + +## Google Play Billing Library 2.2.0 release and Unity support (2020-03-23) + +Version 2.2.0 of the Google Play Billing provides functionality that helps +developers ensure purchases are attributed to the correct user. These changes +replace the need to build custom solutions based on developer payload. As part +of this update, the developer payload functionality has been deprecated and will +be removed in a future release. For more information, including recommended +alternatives, see [Developer payload](https://developer.android.com/google/play/billing/developer-payload). + +### Google Play Billing Billing Library 2 for Unity + +In addition to the current Java and Kotlin versions of Google Play Billing +Library 2, we released a version of the library for use with Unity. Game +developers using the Unity in-app purchase API can upgrade now to take advantage +of all Google Play Billing Library 2 features and to make the subsequent +upgrades to future versions of the Google Play Billing Library easier. + +To learn more, see [Use Google Play Billing with +Unity](https://developer.android.com/google/play/billing/unity). + +### Summary of changes + +- Java Google Play Billing Library + - In [`AcknowledgePurchaseParams`](https://developer.android.com/reference/com/android/billingclient/api/AcknowledgePurchaseParams), deprecated [`setDeveloperPayload()`](https://developer.android.com/reference/com/android/billingclient/api/AcknowledgePurchaseParams.Builder#setDeveloperPayload(java.lang.String)) and [`getDeveloperPayload()`](https://developer.android.com/reference/com/android/billingclient/api/AcknowledgePurchaseParams#getDeveloperPayload()) methods. + - In [`ConsumeParams`](https://developer.android.com/reference/com/android/billingclient/api/ConsumeParams), deprecated [`setDeveloperPayload()`](https://developer.android.com/reference/com/android/billingclient/api/ConsumeParams.Builder#setDeveloperPayload(java.lang.String)) and [`getDeveloperPayload()`](https://developer.android.com/reference/com/android/billingclient/api/ConsumeParams#getdeveloperpayload) methods. + - In [`BillingFlowParams`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder), renamed [`setAccountId()`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setAccountId(java.lang.String)) to [`setObfuscatedAccountId()`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setObfuscatedAccountId(java.lang.String)), and documented length restriction of 64 characters and restriction disallowing Personally Identifiable Information (PII) in this field. [`setAccountId()`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setaccountid) has been marked as deprecated and will be removed in a future library version. + - In `BillingFlowParams`, added [`setObfuscatedProfileId()`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setobfuscatedprofileid) which works similarly to [`setObfuscatedAccountId()`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setObfuscatedAccountId(java.lang.String)). For more information, see [Developer payload updates and + alternatives](https://developer.android.com/google/play/billing/developer-payload). + - In [`Purchase`](https://developer.android.com/reference/com/android/billingclient/api/Purchase), added the [`getAccountIdentifiers()`](https://developer.android.com/reference/com/android/billingclient/api/Purchase#getAccountIdentifiers()) method to return the obfuscated account identifiers set in `BillingFlowParams`. + - In [`BillingClient`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient), the [`loadRewardedSku()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#loadRewardedSku(com.android.billingclient.api.RewardLoadParams,%20com.android.billingclient.api.RewardResponseListener)) method has been marked deprecated as part of deprecating rewarded SKUs. You can find more information about the deprecation in the [Play Console + Help + Center](https://support.google.com/googleplay/android-developer/answer/9155268). + +## Google Play Billing Library 2.1.0 Release and Kotlin Extension 2.1.0 Release (2019-12-10) + +Version 2.1.0 of the Google Play Billing library and the new Kotlin extension +are now available. The Play Billing Library Kotlin extension provides idiomatic +API alternatives for Kotlin consumption, featuring better null-safety and +coroutines. For code examples, see [Use the Google Play Billing +Library](https://developer.android.com/google/play/billing/billing_library_overview). + +This version contains the following changes. + +### Summary of changes + +- In [`BillingFlowParams`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams), deprecated `setOldSku(String oldSku)` and replaced with `setOldSku(String + oldSku, String purchaseToken)`, to disambiguate when multiple accounts on the device own the same sku. + +## Google Play Billing Library 2.0.3 Release (2019-08-05) + +Version 2.0.3 of the Google Play Billing library is now available. + +### Bug fixes + +- Fixed a bug where `querySkuDetailsAsync()` would occasionally fail with code `DEVELOPER_ERROR` instead of returning a successful result. + +## Google Play Billing Library 2.0.2 Release (2019-07-08) + +Version 2.0.2 of the Google Play Billing library is now available. This release +contains updates to the reference documentation and does not change library +functionality. + +## Google Play Billing Library 2.0.1 Release (2019-06-06) + +Version 2.0.1 of the Google Play Billing library is now available. This version +contains the following changes. + +### Bug fixes + +- Fixed a bug where debug messages were being returned as `null` in some cases. +- Fixed a potential memory leak issue. + +## Google Play Billing Library 2.0 Release (2019-05-07) + +Version 2.0 of the Google Play Billing library is now available. This version +contains the following changes. + +### Purchases must be acknowledged within three days + +> [!NOTE] +> **Note:** This section describes a new requirement for acknowledging all purchases. If you are already consuming purchases using `consumeAsync()`, you don't need to make any further changes if you consume purchases within 3 days, as `consumeAsync()` automatically acknowledges a purchase. Failure to properly acknowledge purchases will result in purchases being refunded. + +> [!NOTE] +> **Note:** This requirement applies only to apps that use the Google Play Billing Library version 2.0 and newer. This requirement doesn't apply if you use an older version of the Google Play Billing Library, or if you use the AIDL API. + +Google Play supports purchasing products from inside of your app (in-app) or +outside of your app (out-of-app). In order for Google Play to ensure a +consistent purchase experience regardless of where the user purchases your +product, you must acknowledge all purchases received through the Google Play +Billing Library as soon as possible after granting entitlement to the user. If +you don't acknowledge a purchase within three days, the user automatically +receives a refund, and Google Play revokes the purchase. For [pending +transactions](https://developer.android.com/google/play/billing/release-notes#2_0_pending) (new in version 2.0), the three-day window starts +when the purchase has moved to the `PURCHASED` state and does not apply while +the purchase is in a `PENDING` state. + +For subscriptions, you must acknowledge any purchase that has a new purchase +token. This means that all initial purchases, plan changes, and re-signups need +to be acknowledged, but you don't need to acknowledge subsequent renewals. To +determine if a purchase needs acknowledgment, you can check the acknowledgement +field in the purchase. + +The `Purchase` object now includes an +[`isAcknowledged()`](https://developer.android.com/reference/com/android/billingclient/api/Purchase#isacknowledged) +method that indicates whether a purchase has been acknowledged. In addition, the +Google Play Developer API includes acknowledgement boolean values for both +[`Purchases.products`](https://developers.google.com/android-publisher/api-ref/purchases/products) +and +[`Purchases.subscriptions`](https://developers.google.com/android-publisher/api-ref/purchases/subscriptions). +Before acknowledging a purchase, be sure to use these methods to determine if +the purchase has already been acknowledged. + +You can acknowledge a purchase by using one of the following methods: + +- For consumable products, use `consumeAsync()`, found in the client API. +- For products that aren't consumed, use `acknowledgePurchase()`, found in the client API. +- A new `acknowledge()` method is also available in the Server API. + +### BillingFlowParams.setSku() has been removed + +The previously-deprecated `BillingFlowParams#setSku()` method has been removed +in this release. Before rendering products in a purchase flow, you must now call +[`BillingClient.querySkuDetailsAsync()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#queryskudetailsasync), +passing the resulting +[`SkuDetails`](https://developer.android.com/reference/com/android/billingclient/api/SkuDetails) object to +[`BillingFlowParams.Builder.setSkuDetails()`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setskudetails). + +> [!NOTE] +> **Note:** Caching `SkuDetails` between user sessions is not recommended, as `SkuDetails` objects are valid only for a limited time before you must refresh them again by calling `querySkuDetailsAsync()`. + +For code examples, see [Use the Google Play Billing +Library](https://developer.android.com/google/play/billing/billing_library_overview). + +### Developer payload is supported + +Version 2.0 of the Google Play Billing library adds support for *developer +payload* ---arbitrary strings that can be attached to purchases. You can +attach a developer payload parameter to a purchase, but only when the purchase +is acknowledged or consumed. This is unlike developer payload in AIDL, where the +payload could be specified when launching the purchase flow. Because purchases +can now be initiated [from outside of your app](https://developer.android.com/google/play/billing/release-notes#2_0_acknowledge), this change +ensures that you always have an opportunity to add a payload to purchases. + +To access the payload in the new library, `Purchase` objects now include a +[`getDeveloperPayload()`](https://developer.android.com/reference/com/android/billingclient/api/Purchase#getdeveloperpayload) +method. + +> [!NOTE] +> **Note:** You cannot modify a payload after it is assigned. + +### Consistent offers + +> [!NOTE] +> **Note:** This feature is being tested, and general availability is not guaranteed. + +When you offer a discounted SKU, Google Play now returns the original price of +the SKU so that you can show users that they are receiving a discount. + +[`SkuDetails`](https://developer.android.com/reference/com/android/billingclient/api/SkuDetails) contains two +new methods for retrieving the original SKU price: + +- [`getOriginalPriceAmountMicros()`](https://developer.android.com/reference/com/android/billingclient/api/SkuDetails#getOriginalPriceAmountMicros()) + - returns the unformatted original price of the SKU before discount. +- [`getOriginalPrice()`](https://developer.android.com/reference/com/android/billingclient/api/SkuDetails#getOriginalPrice()) + - returns the original price with additional currency formatting. + +### Pending transactions + +With version 2.0 of the Google Play Billing library, you *must* support +purchases where additional action is required before granting entitlement. For +example, a user might choose to purchase your in-app product at a physical store +using cash. This means that the transaction is completed outside of your app. In +this scenario, you should grant entitlement only after the user has completed +the transaction. + +To enable pending purchases, call +[`enablePendingPurchases()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder#enablependingpurchases) +as part of initializing your app. + +Use +[`Purchase.getPurchaseState()`](https://developer.android.com/reference/com/android/billingclient/api/Purchase#getpurchasestate) +to determine whether the purchase state is `PURCHASED` or `PENDING`. Note that +you should grant entitlement only when the state is `PURCHASED`. You should +check for `Purchase` status updates by doing the following: + +1. When starting your app, call [`BillingClient.queryPurchases()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#querypurchases) to retrieve the list of unconsumed products associated with the user. +2. Call `Purchase.getPurchaseState()` on each returned `Purchase` object. +3. Implement the [`onPurchasesUpdated()`](https://developer.android.com/reference/com/android/billingclient/api/PurchasesUpdatedListener#onpurchasesupdated) method to respond to changes to `Purchase` objects. + +In addition, the Google Play Developer API includes a `PENDING` state for +[`Purchases.products`](https://developers.google.com/android-publisher/api-ref/purchases/products). +Pending transactions are not supported for subscriptions. + +This release also introduces a new real-time developer notification type, +`OneTimeProductNotification`. This notification type contains a single message +whose value is either `ONE_TIME_PRODUCT_PURCHASED` or +`ONE_TIME_PRODUCT_CANCELED`. This notification type is sent only for purchases +associated with delayed forms of payment, such as cash. + +When acknowledging pending purchases, be sure to acknowledge only when the +purchase state is `PURCHASED` and not `PENDING`. + +> [!NOTE] +> **Note:** Pending transactions can be tested using license testers. In addition to two test credit cards, license testers have access to two new test instruments for delayed forms of payment which automatically complete or cancel after a couple of minutes. While testing your application, you should verify that your application does not grant entitlement or acknowledge the purchase immediately after purchasing with either of these two new instruments. When purchasing using the new test instrument that automatically completes, you should verify that your application grants entitlement and acknowledges the purchase once the purchase completes. + +### API changes + +Version 2.0 of the Google Play Billing library contains several API changes to +support new features and clarify existing functionality. + +#### consumeAsync + +[`consumeAsync()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#consumeasync) +now takes a +[`ConsumeParams`](https://developer.android.com/reference/com/android/billingclient/api/ConsumeParams) object +instead of a `purchaseToken`. `ConsumeParams` contains the `purchaseToken` as +well as an optional developer payload. + +The previous version of `consumeAsync()` has been removed in this release. + +#### queryPurchaseHistoryAsync + +To minimize confusion, +[`queryPurchaseHistoryAsync()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#queryPurchaseHistoryAsync(java.lang.String,%20com.android.billingclient.api.PurchaseHistoryResponseListener)) +now returns a +[`PurchaseHistoryRecord`](https://developer.android.com/reference/com/android/billingclient/api/PurchaseHistoryRecord) +object instead of a `Purchase` object. The `PurchaseHistoryRecord` object is the +same as a `Purchase` object, except that it reflects only the values returned by +`queryPurchaseHistoryAsync()` and does not contain the `autoRenewing`, +`orderId`, and `packageName` fields. Note that nothing has changed with the +returned data---`queryPurchaseHistoryAsync()` returns the same data as +before. + +#### BillingResult return values + +APIs that previously returned a `BillingResponse` integer value now return a +[`BillingResult`](https://developer.android.com/reference/com/android/billingclient/api/BillingResult) +object. `BillingResult` contains the `BillingResponse` integer as well as a +debug string that you can use to diagnose errors. The debug string uses an en-US +locale and is not meant to be shown to end users. + +### Bug fixes + +- [`SkuDetails.getIntroductoryPriceAmountMicros()`](https://developer.android.com/reference/com/android/billingclient/api/SkuDetails#getIntroductoryPriceAmountMicros()) now returns a `long` instead of a `String`. + +## Google Play Billing Library 1.2.2 Release (2019-03-07) + +Version 1.2.2 of the Google Play Billing library is now available. This version +contains the following changes. + +### Bug fixes + +- Fixed a threading issue introduced in v1.2.1. Background calls no longer block the main thread. + +### Other changes + +- Although using the main thread is still recommended, you can now instantiate the Google Play Billing Library from a background thread. +- Instantiation has been fully migrated to the background thread to reduce the chance of causing ANRs. + +## Play Billing Library 1.2.1 Release (2019-03-04) + +Version 1.2.1 of the Google Play Billing library is now available. This version +contains the following changes. + +### Major changes + +- Added support for [rewarded + products](https://developer.android.com/google/play/billing/billing_rewarded_products). For more information on monetization options, see [Add rewarded-product-specific + features](https://developer.android.com/distribute/best-practices/earn/monetization-options). + +### Other changes + +- Added public constructors for `PurchasesResult` and `SkuDetailsResult` to make testing easier. +- `SkuDetails` objects can use a new method, `getOriginalJson()`. +- All AIDL service calls are now handled by background threads. + +### Bug fixes + +- Null callback listeners are no longer passed into public APIs. + +## Google Play Billing Library 1.2 Release (2018-10-18) + +Version 1.2 of the Google Play Billing library is now available. This version +contains the following changes. + +### Summary of changes + +- The Google Play Billing Library is now licensed under the [Android Software + Development Kit License Agreement](https://developer.android.com/studio/terms). +- Added the `launchPriceChangeConfirmationFlow` API, which prompts users to review a pending change to a subscription price. +- Added support for a new proration mode, `DEFERRED`, when upgrading or downgrading a user's subscription. +- In the `BillingFlowParams` class, replaced `setSku()` with `setSkuDetails()`. +- Minor bug fixes and code optimizations. + +#### Price change confirmation + +You can now change the price of a subscription in Google Play Console +and prompt users to review and accept the new price when they enter your app. + +To use this API, create a `PriceChangeFlowParams` object by using the +`skuDetails` of the subscription product, and then call +`launchPriceChangeConfirmationFlow()`. Implement the +`PriceChangeConfirmationListener` to handle the result when the price change +confirmation flow finishes, as shown in the following code snippet: + +### Kotlin + +```kotlin +val priceChangeFlowParams = PriceChangeFlowParams.newBuilder() + .setSkuDetails(skuDetailsOfThePriceChangedSubscription) + .build() + +billingClient.launchPriceChangeConfirmationFlow(activity, + priceChangeFlowParams, + object : PriceChangeConfirmationListener() { + override fun onPriceChangeConfirmationResult(responseCode: Int) { + // Handle the result. + } + }) +``` + +### Java + +```java +PriceChangeFlowParams priceChangeFlowParams = + PriceChangeFlowParams.newBuilder() + .setSkuDetails(skuDetailsOfThePriceChangedSubscription) + .build(); + +billingClient.launchPriceChangeConfirmationFlow(activity, + priceChangeFlowParams, + new PriceChangeConfirmationListener() { + @Override + public void onPriceChangeConfirmationResult(int responseCode) { + // Handle the result. + } + }); +``` + +The price change confirmation flow displays a dialog containing the new pricing +information, asking users to accept the new price. This flow returns a response +code of type +[`BillingClient.BillingResponse`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingResponse). + +#### New proration mode + +When upgrading or downgrading a user's subscription, you can use a new proration +mode, `DEFERRED`. This mode updates the user's subscription when it next renews. +To learn more about how to set this proration mode, see [Set proration +mode](https://developer.android.com/google/play/billing/billing_subscriptions#set-proration-mode). + +#### New method for setting SKU details + +In the `BillingFlowParams` class, the `setSku()` method has been deprecated. +This change serves to optimize the Google Play Billing flow. + +When constructing a new instance of `BillingFlowParams` in your in-app billing +client, we recommend that you instead work with the JSON object directly using +`setSkuDetails()`, as shown in the following code snippet: + +In the `BillingFlowParams` Builder class, the `setSku()` method has been +deprecated. Instead, use the `setSkuDetails()` method, as shown in the following +code snippet. The object passed into `setSkuDetails()` object comes from the +[`querySkuDetailsAsync()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#querySkuDetailsAsync(com.android.billingclient.api.SkuDetailsParams,%20com.android.billingclient.api.SkuDetailsResponseListener)) +method. + +### Kotlin + +```kotlin +private lateinit var mBillingClient: BillingClient +private val mSkuDetailsMap = HashMap() + +private fun querySkuDetails() { + val skuDetailsParamsBuilder = SkuDetailsParams.newBuilder() + mBillingClient.querySkuDetailsAsync(skuDetailsParamsBuilder.build() + ) { responseCode, skuDetailsList -> + if (responseCode == 0) { + for (skuDetails in skuDetailsList) { + mSkuDetailsMap[skuDetails.sku] = skuDetails + } + } + } +} + +private fun startPurchase(skuId: String) { + val billingFlowParams = BillingFlowParams.newBuilder() + .setSkuDetails(mSkuDetailsMap[skuId]) + .build() +} +``` + +### Java + +```java +private BillingClient mBillingClient; +private Map mSkuDetailsMap = new HashMap<>(); + +private void querySkuDetails() { + SkuDetailsParams.Builder skuDetailsParamsBuilder + = SkuDetailsParams.newBuilder(); + mBillingClient.querySkuDetailsAsync(skuDetailsParamsBuilder.build(), + new SkuDetailsResponseListener() { + @Override + public void onSkuDetailsResponse(int responseCode, + List skuDetailsList) { + if (responseCode == 0) { + for (SkuDetails skuDetails : skuDetailsList) { + mSkuDetailsMap.put(skuDetails.getSku(), skuDetails); + } + } + } + }); +} + +private void startPurchase(String skuId) { + BillingFlowParams billingFlowParams = BillingFlowParams.newBuilder() + .setSkuDetails(mSkuDetailsMap.get(skuId)) + .build(); +} +``` + +## Play Billing Library 1.1 Release (2018-05-07) + +Version 1.1 of the Google Play Billing library is now available. This version +contains the following changes. + +### Summary of changes + +- Added support to specify a proration mode in [`BillingFlowParams`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams) when upgrading/downgrading an existing subscription. +- The `replaceSkusProration` boolean flag in [`BillingFlowParams`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams) is no longer supported. Use `replaceSkusProrationMode` instead. +- [`launchBillingFlow()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.html#launchBillingFlow(android.app.Activity,%0Acom.android.billingclient.api.BillingFlowParams)) now triggers a callback for failed responses. + +### Behavior changes + +Version 1.1 of the Google Play Billing library contains the following behavior +changes. + +#### Developers can set `replaceSkusProrationMode` in [`BillingFlowParams`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams) class + +A `ProrationMode` provides further details on the type of proration when +upgrading or downgrading a user's subscription. + +### Kotlin + +```kotlin +BillingFlowParams.newBuilder() + .setSku(skuId) + .setType(billingType) + .setOldSku(oldSku) + .setReplaceSkusProrationMode(replaceSkusProrationMode) + .build() +``` + +
+ +
+ +### Java + +```java +BillingFlowParams.newBuilder() + .setSku(skuId) + .setType(billingType) + .setOldSku(oldSku) + .setReplaceSkusProrationMode(replaceSkusProrationMode) + .build(); +``` + +
+ +Google Play supports following proration modes: + +|---|---| +| `IMMEDIATE_WITH_TIME_PRORATION` | Replacement takes effect immediately, and the new expiration time will be prorated and credited or charged to the user. This is the current default behavior. | +| `IMMEDIATE_AND_CHARGE_PRORATED_PRICE` | Replacement takes effect immediately, and the billing cycle remains the same. The price for the remaining period will be charged. **Note**: This option is only available for subscription upgrade. | +| `IMMEDIATE_WITHOUT_PRORATION` | Replacement takes effect immediately, and the new price will be charged on next recurrence time. The billing cycle stays the same. | + +### `replaceSkusProration` is no longer supported in [`BillingFlowParams`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.html) class + +Developers used to be able to set a boolean flag to charge a prorated amount for +a subscription upgrade request. Given that we are supporting `ProrationMode`, +which contains more detailed proration instruction, this boolean flag is no +longer supported. + +### [`launchBillingFlow()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.html#launchBillingFlow(android.app.Activity,%20com.android.billingclient.api.BillingFlowParams)) now triggers a callback for failed responses + +The Billing Library will always trigger the +[`PurhcasesUpdatedListener`](https://developer.android.com/reference/com/android/billingclient/api/PurchasesUpdatedListener) +callback and return a +[`BillingResponse`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingResponse) +asynchronously. The synchronous return value of +[`BillingResponse`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingResponse) +is kept as well. + +### Bug fixes + +- Properly exits early in async methods when service is disconnected. +- [`Builder`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder) param objects no longer mutates built objects. +- Issue [68087141](https://issuetracker.google.com/issues/68087141): [`launchBillingFlow()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.html#launchBillingFlow(android.app.Activity,%0Acom.android.billingclient.api.BillingFlowParams)) now trigger callback for failed responses. + +## Google Play Billing Library 1.0 Release (2017-09-19, [Announcement](https://android-developers.googleblog.com/2017/09/google-play-billing-library-10-released.html)) + +Version 1.0 of the Google Play Billing library is now available. This version +contains the following changes. + +### Important changes + +- Embedded billing permission inside library's manifest. It's not necessary to add the `com.android.vending.BILLING` permission inside Android manifest anymore. +- New builder added to [`BillingClient.Builder`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder.html) class. +- Introduced builder pattern for [`SkuDetailsParams`](https://developer.android.com/reference/com/android/billingclient/api/SkuDetailsParams.html) class to be used on methods to query SKUs. +- Several API methods were updated for consistency (the same return argument names and order). + +### Behavior changes + +Version 1.0 of the Google Play Billing library contains the following behavior +changes. + +#### BillingClient.Builder class + +[`BillingClient.Builder`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder.html) +is now initialized via the newBuilder pattern: + +### Kotlin + +```kotlin +billingClient = BillingClient.newBuilder(context).setListener(this).build() +``` + +### Java + +```java +billingClient = BillingClient.newBuilder(context).setListener(this).build(); +``` + +#### launchBillingFlow method is now called using a BillingFlowParams class + +To initiate the billing flow for a purchase or subscription, the +[`launchBillingFlow()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.html#launchBillingFlow(android.app.Activity,%0Acom.android.billingclient.api.BillingFlowParams)) method receives a +[`BillingFlowParams`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.html) +instance initialized with parameters specific to the request: + +### Kotlin + +```kotlin +BillingFlowParams.newBuilder().setSku(skuId) + .setType(billingType) + .setOldSku(oldSku) + .build() + +// Then, use the BillingFlowParams to start the purchase flow +val responseCode = billingClient.launchBillingFlow(builder.build()) +``` + +### Java + +```java +BillingFlowParams.newBuilder().setSku(skuId) + .setType(billingType) + .setOldSku(oldSku) + .build(); + +// Then, use the BillingFlowParams to start the purchase flow +int responseCode = billingClient.launchBillingFlow(builder.build()); +``` + +#### New way to query available products + +Arguments for +[`queryPurchaseHistoryAsync()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.html#queryPurchaseHistoryAsync(java.lang.String,%0Acom.android.billingclient.api.PurchaseHistoryResponseListener)) and +[`querySkuDetailsAsync()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.html#querySkuDetailsAsync(com.android.billingclient.api.SkuDetailsParams,%0Acom.android.billingclient.api.SkuDetailsResponseListener)) methods were wrapped +into a Builder pattern: + +### Kotlin + +```kotlin +val params = SkuDetailsParams.newBuilder() +params.setSkusList(skuList) + .setType(itemType) +billingClient.querySkuDetailsAsync(params.build(), object : SkuDetailsResponseListener() { + ... +}) +``` + +### Java + +```java +SkuDetailsParams.Builder params = SkuDetailsParams.newBuilder(); +params.setSkusList(skuList) + .setType(itemType); +billingClient.querySkuDetailsAsync(params.build(), new SkuDetailsResponseListener() {...}) +``` + +The result is now returned via result code and a list of +[`SkuDetails`](https://developer.android.com/reference/com/android/billingclient/api/SkuDetails.html) +objects instead of previous wrapper class for your convenience and to be +consistent across our API: + +### Kotlin + +```kotlin +fun onSkuDetailsResponse(@BillingResponse responseCode: Int, skuDetailsList: List) +``` + +### Java + +```java +public void onSkuDetailsResponse(@BillingResponse int responseCode, List skuDetailsList) +``` + +#### Parameters order changed on `onConsumeResponse()` method + +The order of arguments for +[`onConsumeResponse`](https://developer.android.com/reference/com/android/billingclient/api/ConsumeResponseListener.html#onConsumeResponse(int,%0Ajava.lang.String)) from the +[`ConsumeResponseListener`](https://developer.android.com/reference/com/android/billingclient/api/ConsumeResponseListener.html) +interface has changed to be consistent across our API: + +### Kotlin + +```kotlin +fun onConsumeResponse(@BillingResponse responseCode: Int, outToken: String) +``` + +### Java + +```java +public void onConsumeResponse(@BillingResponse int responseCode, String outToken) +``` + +#### Unwrapped PurchaseResult object + +[`PurchaseResult`](https://developer.android.com/reference/com/android/billingclient/api/Purchase.PurchasesResult.html) +has been unwraped to be consistent across our API: + +### Kotlin + +```kotlin +fun onPurchaseHistoryResponse(@BillingResponse responseCode: Int, purchasesList: List) +``` + +### Java + +```java +void onPurchaseHistoryResponse(@BillingResponse int responseCode, List purchasesList) +``` + +### Bug fixes + +- [No response code in PURCHASES_UPDATED Bundle](https://issuetracker.google.com/issues/64075043) +- [Fix ProxyBillingActivity and PurchasesUpdatedListener issues during device + rotation](https://issuetracker.google.com/issues/63266562) + +## Developer Preview 1 Release (2017-06-12, [Announcement](https://android-developers.googleblog.com/2017/06/money-made-easily-with-new-google-play.html)) + +Developer preview launched, aimed to simplify the development process when it +comes to billing, allowing developers to focus their efforts on implementing +logic specific to the Android app, such as application architecture and +navigation structure. + +The library includes several convenient classes and features for you to use when +integrating your Android apps with the Google Play Billing API. The library also +provides an abstraction layer on top of the Android Interface Definition +Language (AIDL) service, making it easier for developers to define the interface +between the app and the Google Play Billing API. \ No newline at end of file diff --git a/.agents/skills/play/play-billing-library-version-upgrade/references/migration-logic.md b/.agents/skills/play/play-billing-library-version-upgrade/references/migration-logic.md new file mode 100644 index 00000000..ac4d9c5b --- /dev/null +++ b/.agents/skills/play/play-billing-library-version-upgrade/references/migration-logic.md @@ -0,0 +1,76 @@ +## High-Impact Migration Logic + +### 1. Connection Management (The v8+ Reconnection Shift) + +**Intent**: Move from developer-managed state (manual retries) to +library-managed state. + +- **Remove** : Manual `startConnection()` calls or retry timers inside `onServiceDisconnected()`. +- **Add** : `.enableAutoServiceReconnection()` to `BillingClient.Builder`. +- **Logic** : In v8+, the library handles transient disconnections. Your `onServiceDisconnected` must only be used for logging or updating UI state (e.g., "Billing service temporarily unavailable"). + +### 2. Product Querying \& Models (The v5-v8 Architectural Shift) + +**Intent**: Support the "One Product, Multiple Offers" model introduced in v5 +and refined in v8. + +- **Data Model Swap** : + - **Legacy** : `SkuDetails` (1:1 mapping of ID to price). + - **Modern** : `ProductDetails`. A single `ProductDetails` can contain multiple `SubscriptionOfferDetails` (Base Plans + Offers). +- **Result Handling (v8+ Logic)** : + - **Change** : `queryProductDetailsAsync` no longer returns a list in the listener. + - **New Intent** : You must receive a `QueryProductDetailsResult` object. + - **Refactor** : `kotlin + // PBL 8+ Pattern + billingClient.queryProductDetailsAsync(params) { result: QueryProductDetailsResult -> + val responseCode = result.billingResult.responseCode + val productDetailsList = result.productDetailsList // Retrieve list from result object + // Process list... + }` + +### 3. Subscription Modernization (v6 \& v7) + +**Intent**: Support "Base Plans" and "Offers" instead of legacy standalone SKUs. + +- **Subscription Upgrades/Downgrades (v6+)**: + + - **Legacy** : `setOldSkuPurchaseToken()` in `BillingFlowParams`. + - **Modern** : Use `SubscriptionUpdateParams`. You must specify the `PurchaseToken` of the existing subscription and the `ReplacementMode` (which replaces the deprecated `ProrationMode`). + - **Logic** : Verify that the `ReplacementMode` matches the business intent (e.g., `CHARGE_FULL_PRICE` versus `WITH_TIME_PRORATION`). +- **Installment Plans (v7+)**: + + - **Intent**: Allow users to pay for a subscription in monthly installments. + - **Check** : Look for `InstallmentPlanDetails` within `SubscriptionOfferDetails`. If the app supports high-ticket subscriptions, it is mandatory to implement the `installmentPlanDetails` UI. + +### 4. Purchase Handling \& History (v6+) + +**Intent**: Move away from local-only purchase caches to real-time status +checks. + +- **Active Purchases** : + - **Deprecated** : `queryPurchases()` (synchronous). + - **Mandatory** : `queryPurchasesAsync()`. You must pass `QueryPurchasesParams` containing the `ProductType` (`INAPP` or `SUBS`). +- **Purchase History (Pagination Intent)** : + - **v6+ Change** : `queryPurchaseHistoryAsync` is optimized for pagination. + - **Logic** : If the app has thousands of historical transactions, verify you are using the `PurchaseHistoryRecord` list correctly to avoid memory overhead. + +### 5. Security \& Pending Transactions (The "Always On" Rule) + +- **Mandatory** : `enablePendingPurchases()` has been required since v3, but in v8+, verify that it is called before `.build()`. You must also include `.enableOneTimeProducts()` on the `enablePendingPurchases()` builder. +- **Optional** : If the app sells prepaid subscriptions, you must also include `.enablePrepaidPlans()`. +- **Intent**: This handles "Slow/Delayed" payments (like cash or bank transfers). Without this, the app will crash on initialization in modern versions. + +### 6. SDK \& Environment Requirements + +- **PBL 7.0** : Requires `compileSdk 34` or higher. +- **PBL 8.0** : Requires `compileSdk 35`. +- **Kotlin** : Verify that `kotlin-stdlib` is updated to at least 1.9.x to support new library coroutine extensions. + +### 7. User-Facing Features (Post-Upgrade Recommendations) + +Once the upgrade is complete, the following features are enabled by these +versions: + +- **v7** : **Installments** (Monthly payments for annual plans). +- **v8** : **Prepaid Plans** (Users can top-up time without auto-renewing). +- **v8** : **Personalized Pricing** (Show legal disclosure if price varies by user). \ No newline at end of file diff --git a/.agents/skills/play/play-billing-library-version-upgrade/references/version-checklist.md b/.agents/skills/play/play-billing-library-version-upgrade/references/version-checklist.md new file mode 100644 index 00000000..24ffd41b --- /dev/null +++ b/.agents/skills/play/play-billing-library-version-upgrade/references/version-checklist.md @@ -0,0 +1,46 @@ +## Play Billing Library: Smart Version-Specific Checklist + +Use this checklist to verify that every technical requirement between your +\[Current Version\] and \[Target Version\] has been met. + +## PBL v1.x through v3.x + +- \[ \] **\[v1.0\] Builder Pattern** : Verify `BillingClient.newBuilder(context)` is used. +- \[ \] **\[v2.0\] Mandatory Acknowledgment** : Verify `acknowledgePurchase()` or `consumeAsync()` is called within 3 days. +- \[ \] **\[v2.0\] Response Types** : Logic must handle `BillingResult` objects instead of raw integers. +- \[ \] **\[v3.0\] Legacy Removal** : Verify `ChildDirected` and `UnderAgeOfConsent` parameters are deleted. + +## PBL v4.x Series + +- \[ \] **Async Purchasing** : Confirm `queryPurchases()` is replaced with `queryPurchasesAsync()`. +- \[ \] **Multi-SKU Accessors** : Replace `getSku()` with `getSkus()` (returns a list) in `Purchase` objects. +- \[ \] **Subscription Refactor** : Verify `setSubscriptionUpdateParams()` is used for change logic. + +## PBL v5.x Series + +- \[ \] **Data Model Swap** : Replace all instances of `SkuDetails` with `ProductDetails`. +- \[ \] **Personalized Pricing** : Implement `setIsOfferPersonalized()` for EU price disclosures. + +## PBL v6.x Series + +- \[ \] **Replacement Mode** : Replace `ProrationMode` with the `ReplacementMode` enum. +- \[ \] **User Choice Billing** : Replace `AlternativeBillingListener` with `UserChoiceBillingListener`. + +## PBL v7.x Series + +- \[ \] **SDK Compliance** : `compileSdk` is set to 34 or higher. +- \[ \] **Pending Purchases** : Replace parameterless `enablePendingPurchases()` with `enablePendingPurchases(PendingPurchaseParams)`. +- \[ \] **API Cleanup** : Replace `setOldSkuPurchaseToken()` with `setOldPurchaseToken()`. + +## PBL v8.x Series + +- \[ \] **SDK Compliance** : `compileSdk` is set to 35. +- \[ \] **Terminology Shift**: Rename "in-app items" to "one-time products" in UI/strings. +- \[ \] **Signature Enforcement** : `onProductDetailsResponse` signature MUST be `(BillingResult, QueryProductDetailsResult)`. +- \[ \] **Auto-Reconnection** : Verify `enableAutoServiceReconnection()` is used in the builder. +- \[ \] **Min SDK Increase** : Verify `minSdkVersion` is at least 23. + +## Future Versions (PBL 9.0.0+) + +- \[ \] **Dynamic Checklist Generation** : For any version \>=9.0.0, you **MUST** synthesize a new checklist for each new version header found in the [Release Notes](https://developer.android.com/google/play/billing/release-notes). +- \[ \] **Identify Version Delta** : Review "Breaking Changes" and "Removed APIs" for the new version and create a list of terms to `grep`. \ No newline at end of file diff --git a/.agents/skills/system/edge-to-edge/SKILL.md b/.agents/skills/system/edge-to-edge/SKILL.md new file mode 100644 index 00000000..a4a42077 --- /dev/null +++ b/.agents/skills/system/edge-to-edge/SKILL.md @@ -0,0 +1,425 @@ +--- +name: edge-to-edge +description: Use this skill to migrate your Jetpack Compose app to add adaptive edge-to-edge + support and troubleshoot common issues. Use this skill to fix UI components (like + buttons or lists) that are obscured by or overlapping with the navigation bar or + status bar, fix IME insets, and fix system bar legibility. +license: Complete terms in LICENSE.txt +metadata: + author: Google LLC + keywords: + - android + - compose + - system bars + - edge-to-edge + - status bar + - navigation bar +--- + +## Prerequisites + +- Project **MUST** use Android Jetpack Compose. +- Project **MUST** target SDK 35 or later. If the SDK is lower than 35, increase the SDK to 35. + +## Step 1: plan + +1. Locate and analyze all Activity classes to detect which have existing edge-to-edge support. For every Activity without edge-to-edge, plan to make each Activity edge-to-edge. +2. In each Activity, Locate and analyze all lists and FAB components to detect which have existing edge-to-edge support. For every component without edge-to-edge support, plan to make each of these components edge-to-edge. +3. In each Activity, scan for `TextField`, `OutlinedTextField`, or `BasicTextField`. If found, then you **MUST** verify the IME doesn't hide the input field by following the IME section of this skill. + +## Step 2: add edge-to-edge support + +1. Add `enableEdgeToEdge` before `setContent` in `onCreate` in each Activity that does not already call `enableEdgeToEdge`. +2. Add `android:windowSoftInputMode="adjustResize"` in the AndroidManifest.xml for all Activities that use a soft keyboard. + +## Step 3: apply insets + +- The app **MUST** apply system insets, or align content to rulers, so critical + UI remains tappable. Choose only one method to avoid double padding: + + 1. **PREFERRED:** When available, use `Scaffold`s and pass `PaddingValues` to the content lambda. + + + ```kotlin + Scaffold { innerPadding -> + // innerPadding accounts for system bars and any Scaffold components + LazyColumn( + modifier = Modifier + .fillMaxSize() + .consumeWindowInsets(innerPadding), + contentPadding = innerPadding + ) { /* Content */ } + } + ``` + +
+ + 1. **PREFERRED:** When available, use the automatic inset handling or padding modifiers in material components. + + - Material 3 Components manages safe areas for its own components, including: + - `TopAppBar` + - `SmallTopAppBar` + - `CenterAlignedTopAppBar` + - `MediumTopAppBar` + - `LargeTopAppBar` + - `BottomAppBar` + - `ModalDrawerSheet` + - `DismissibleDrawerSheet` + - `PermanentDrawerSheet` + - `ModalBottomSheet` + - `NavigationBar` + - `NavigationRail` + - For Material 2 Components, use the `windowInsets`parameter to apply insets manually for `BottomAppBar`, `TopAppBar` and `BottomNavigation`. **DO NOT** apply padding to the parent container; instead, pass insets directly to the App Bar component. Applying padding to the parent container prevents the App Bar background from drawing into the system bar area. For example, for `TopAppBar`, choose only one of the following options: + 1. **PREFERRED:** `TopAppBar(windowInsets = AppBarDefaults.topAppBarWindowInsets)` + 2. `TopAppBar(windowInsets = WindowInsets.systemBars.exclude(WindowInsets.navigationBars))` + 3. `TopAppBar(windowInsets = WindowInsets.systemBars.add(WindowInsets.captionBar))` + 2. For components outside a Scaffold, use padding modifiers, such as `Modifier.safeDrawingPadding()` or `Modifier.windowInsetsPadding(WindowInsets.safeDrawing)`. + + + ```kotlin + Box( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + ) { + Button( + onClick = {}, + modifier = Modifier.align(Alignment.BottomCenter) + ) { + Text("Login") + } + } + ``` + +
+ + 3. For deeply nested components with excessive padding, use `WindowInsetsRulers` (e.g. `Modifier.fitInside(WindowInsetsRulers.SafeDrawing.current)`). See the *IME* section for a code sample. + + 4. When you need an element (e.g. a custom header or decorative scrim) to + equal the dimensions of a system bar, use inset size modifiers (e.g. + `Modifier.windowInsetsTopHeight(WindowInsets.systemBars)`). + See the *Lists* section for a code sample. + +## Adaptive Scaffolds + +- `NavigationSuiteScaffold` manages safe areas for its own components, like the `NavigationRail` or `NavigationBar`. However, the adaptive scaffolds (e.g. `NavigationSuiteScaffold`, `ListDetailPaneScaffold`) don't propagate PaddingValues to their inner contents. You **MUST** apply insets to **individual** screens or components (e.g., list `contentPadding` or FAB padding) as described in *Step 3* . **DO NOT** apply `safeDrawingPadding` or similar modifiers to the `NavigationSuiteScaffold` parent. This clips and prevents an edge-to-edge screen. + +## IME + +- For each Activity with a soft keyboard, check that `android:windowSoftInputMode="adjustResize"` is set in the AndroidManifest.xml. DO NOT use `SOFT_INPUT_ADJUST_RESIZE` because it is deprecated. Then, maintain focus on the input field. Choose one: + - 1. **PREFERRED:** Add `Modifier.fitInside(WindowInsetsRulers.Ime.current)` to the content container. This is preferred over `imePadding()` because it reduces jank and extra padding caused by forgetting to consume insets upstream in the hierarchy. + - 2. Add `imePadding` to the content container. The padding modifier **MUST** be placed before `Modifier.verticalScroll()`. Do NOT use `Modifier.imePadding()` if the parent already accounts for the IME with `contentWindowInsets` (e.g. `contentWindowInsets = + WindowInsets.safeDrawing`). Doing so will cause double padding. + +### IMEs with Scaffolds code patterns + +#### RIGHT + +RIGHT because `contentWindowInsets` contains IME insets, which are passed to the +content lambda as `innerPadding`. + + +```kotlin +// RIGHT +Scaffold(contentWindowInsets = WindowInsets.safeDrawing) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .consumeWindowInsets(innerPadding) + .verticalScroll(rememberScrollState()) + ) { /* Content */ } +} +``` + +
+ +*** ** * ** *** + +RIGHT because `fitInside` fits the content to the IME insets regardless of +`contentWindowInsets`. + + +```kotlin +// RIGHT +Scaffold() { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .consumeWindowInsets(innerPadding) + .fitInside(WindowInsetsRulers.Ime.current) + .verticalScroll(rememberScrollState()) + ) { /* Content */ } +} +``` + +
+ +*** ** * ** *** + +RIGHT because the default `contentWindowInsets` does not contain IME insets, and +`imePadding()` applies IME insets: + + +```kotlin +// RIGHT +Scaffold() { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .consumeWindowInsets(innerPadding) + .imePadding() + .verticalScroll(rememberScrollState()) + ) { /* Content */ } +} +``` + +
+ +#### WRONG + +WRONG because there will be excess padding when the IME opens. IME insets are +applied twice, once with innerPadding, which contains IME insets from the passed +`contentWindowInsets` values, and once with `imePadding`: + + +```kotlin +// WRONG +Scaffold( contentWindowInsets = WindowInsets.safeDrawing ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .imePadding() + .verticalScroll(rememberScrollState()) + ) { /* Content */ } +} +``` + +
+ +*** ** * ** *** + +WRONG because the IME will cover up the content. Scaffold's default +`contentWindowInsets` does NOT contain IME insets. + + +```kotlin +// WRONG +Scaffold() { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + ) { /* Content */ } +} +``` + +
+ +### IMEs without Scaffolds code patterns + +#### RIGHT + +The following code samples WILL NOT cause excessive padding. + + +```kotlin +// RIGHT +Box( + // Insets consumed + modifier = Modifier.safeDrawingPadding() // or imePadding(), safeContentPadding(), safeGesturesPadding() +) { + Column( + modifier = Modifier.imePadding() + ) { /* Content */ } +} +``` + +
+ +*** ** * ** *** + + +```kotlin +// RIGHT +Box( + // Insets consumed + modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing) // or WindowInsets.ime, WindowInsets.safeContent, WindowInsets.safeGestures +) { + Column( + modifier = Modifier.imePadding() + ) { /* Content */ } +} +``` + +
+ +*** ** * ** *** + + +```kotlin +// RIGHT +Box( + // Insets not consumed, but irrelevant due to fitInside + modifier = Modifier.padding(WindowInsets.safeDrawing.asPaddingValues()) // or WindowInsets.ime.asPaddingValues(), WindowInsets.safeContent.asPaddingValues(), WindowInsets.safeGestures.asPaddingValues() +) { + Column( + modifier = Modifier + .fillMaxSize() + .fitInside(WindowInsetsRulers.Ime.current) + ) { /* Content */ } +} +``` + +
+ +#### WRONG + +The following code sample WILL cause excessive padding because IME insets are +applied twice: + + +```kotlin +// WRONG +Box( + // Insets not consumed + modifier = Modifier.padding(WindowInsets.safeDrawing.asPaddingValues()) // or WindowInsets.ime.asPaddingValues(), WindowInsets.safeContent.asPaddingValues(), WindowInsets.safeGestures.asPaddingValues() +) { + Column( + modifier = Modifier.imePadding() + ) { /* Content */ } +} +``` + +
+ +## Navigation Bar Contrast \& System Bar Icons + +- If the Activity uses `enableEdgeToEdge` from `WindowCompat`, you **MUST** set + `isAppearanceLightNavigationBars` and `isAppearanceLightStatusBars` to the + inverse of the device theme for apps that support light and dark theme so the + system bar icons are legible. It's recommended to do this in your theme file. + DO NOT do this if the Activities use `enableEdgeToEdge` from `ComponentActivity` + because it handles the icon colors automatically. + + + ```kotlin + // Only use if calling `enableEdgeToEdge` from `WindowCompat`. + // Apply to your theme file. + @Composable + fun MyTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit + ) { + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as? Activity)?.window ?: return@SideEffect + val controller = WindowCompat.getInsetsController(window, view) + + // Dark icons for Light Mode (!darkTheme), Light icons for Dark Mode + controller.isAppearanceLightStatusBars = !darkTheme + controller.isAppearanceLightNavigationBars = !darkTheme + } + } + + MaterialTheme(content = content) + } + ``` + +
+ +- If any screen uses a `Scaffold` or a `NavigationSuiteScaffold` with a bottom + bar (e.g., `BottomAppBar`, `NavigationBar`), set + `window.isNavigationBarContrastEnforced = false` in the corresponding Activity + for SDK 29+. This prevents the system from adding a translucent background to + the navigation bar, verifying your bottom bar colors extend to the bottom of the + screen. + +## Lists + +- Apply inset padding (like `Scaffold`'s `innerPadding`) to the `contentPadding` parameter of scrollable components (e.g. `LazyColumn`, `LazyRow`). DO NOT apply it as a `Modifier.padding()` to the list's parent container, as this clips the content and prevents it from scrolling behind the system bars. +- Create a translucent composable covering the system bar so that the icons are still legible. + + +```kotlin +class SystemBarProtectionSnippets : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // enableEdgeToEdge sets window.isNavigationBarContrastEnforced = true + // which is used to add a translucent scrim to three-button navigation + enableEdgeToEdge() + + setContent { + MyTheme { + // Main content + MyContent() + + // After drawing main content, draw status bar protection + StatusBarProtection() + } + } + } +} + +@Composable +private fun StatusBarProtection( + color: Color = MaterialTheme.colorScheme.surfaceContainer, +) { + Spacer( + modifier = Modifier + .fillMaxWidth() + .height( + with(LocalDensity.current) { + (WindowInsets.statusBars.getTop(this) * 1.2f).toDp() + } + ) + .background( + brush = Brush.verticalGradient( + colors = listOf( + color.copy(alpha = 1f), + color.copy(alpha = 0.8f), + Color.Transparent + ) + ) + ) + ) +} +``` + +
+ +## Dialogs + +If both the following conditions are true, then the Dialog is full screen and +must be made edge-to-edge: +1. The `DialogProperties` contains `usePlatformDefaultWidth = false`. +2. The Dialog calls `Modifier.fillMaxSize()`. + +To make a full screen Dialog edge-to-edge, set `decorFitsSystemWindows = false` +in the `DialogProperties`. + + +```kotlin +Dialog( + onDismissRequest = { /* Handle dismiss */ }, + properties = DialogProperties( + // 1. Allows the dialog to span the full width of the screen + usePlatformDefaultWidth = false, + // 2. Allows the dialog to draw behind status and navigation bars + decorFitsSystemWindows = false + ) +) { /* Content */ } +``` + +
+ +## Checklist + +- \[ \] Does every `Activity` call `enableEdgeToEdge()`? +- \[ \] Is `adjustResize` set in the `AndroidManifest.xml`? +- \[ \] Does every `TextField`, `OutlinedTextField`, or `BasicTextField` have a parent with `imePadding()`, `fitInside`, `Modifier.safeDrawingPadding()`, `Modifier.safeContentPadding()`, `Modifier.safeGesturesPadding()`, or `contentWindowInsets` set to `WindowInsets.safeDrawing` or `WindowInsets.ime`? +- \[\] Does the first and last list item draw away from the system bars by passing insets to `contentPadding`? +- \[\] Do FABs draw above the navigation bars by either being inside a Scaffold or by applying `Modifier.safeDrawingPadding()`? +- \[\] Does the project build? Run `./gradlew build` to be sure. \ No newline at end of file From f08fa87dd0464e498ea2168acb04f2d7ace1d7df Mon Sep 17 00:00:00 2001 From: nhoxbypass Date: Sat, 18 Apr 2026 23:06:03 +0700 Subject: [PATCH 3/5] [Main Tab] Remove "Recently Visited" feature from Main screen - Delete `MainRecentlyFragment`, `MainRecentlyViewModel`, and `RecentlyStoreAdapter`. - Remove corresponding layouts `fragment_main_recently.xml` and `item_recently_location.xml`. - Update `MainPagerAdapter` to reduce page count from 3 to 2, removing the "Recently" tab. - Adjust `offscreenPageLimit` in `MainFragment` to reflect the reduced number of tabs. --- .../fastfoodfinder/ui/main/MainFragment.kt | 2 +- .../ui/main/MainPagerAdapter.kt | 10 +- .../ui/main/recently/MainRecentlyFragment.kt | 118 -------------- .../ui/main/recently/MainRecentlyViewModel.kt | 37 ----- .../ui/main/recently/RecentlyStoreAdapter.kt | 151 ------------------ .../res/layout/fragment_main_recently.xml | 14 -- .../res/layout/item_recently_location.xml | 55 ------- 7 files changed, 5 insertions(+), 382 deletions(-) delete mode 100644 app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/recently/MainRecentlyFragment.kt delete mode 100644 app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/recently/MainRecentlyViewModel.kt delete mode 100644 app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/recently/RecentlyStoreAdapter.kt delete mode 100644 app/src/main/res/layout/fragment_main_recently.xml delete mode 100644 app/src/main/res/layout/item_recently_location.xml diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainFragment.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainFragment.kt index 9f3b0b91..8a006ec7 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainFragment.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainFragment.kt @@ -48,7 +48,7 @@ class MainFragment : Fragment() { val mPagerAdapter = MainPagerAdapter(childFragmentManager) mViewPager.adapter = mPagerAdapter - mViewPager.offscreenPageLimit = 2 + mViewPager.offscreenPageLimit = 1 mTabLayout.setupWithViewPager(mViewPager) diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainPagerAdapter.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainPagerAdapter.kt index bbc30ff1..4f76f720 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainPagerAdapter.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainPagerAdapter.kt @@ -6,20 +6,18 @@ import androidx.fragment.app.FragmentPagerAdapter import com.iceteaviet.fastfoodfinder.R import com.iceteaviet.fastfoodfinder.ui.main.favourite.MainFavouriteFragment import com.iceteaviet.fastfoodfinder.ui.main.map.MainMapFragment -import com.iceteaviet.fastfoodfinder.ui.main.recently.MainRecentlyFragment /** * Created by Genius Doan on 11/8/2016. */ class MainPagerAdapter internal constructor(fm: FragmentManager) : FragmentPagerAdapter(fm) { - private val pageTitles = arrayOf(R.string.map.toString(), R.string.recently.toString(), R.string.favourite.toString()) - private val imageResId = intArrayOf(R.drawable.ic_main_map, R.drawable.ic_main_clock_red, R.drawable.ic_main_star_red) + private val pageTitles = arrayOf(R.string.map.toString(), R.string.favourite.toString()) + private val imageResId = intArrayOf(R.drawable.ic_main_map, R.drawable.ic_main_star_red) override fun getItem(position: Int): Fragment { return when (position) { 0 -> MainMapFragment.newInstance() - 1 -> MainRecentlyFragment.newInstance() - 2 -> MainFavouriteFragment.newInstance() + 1 -> MainFavouriteFragment.newInstance() else -> MainMapFragment.newInstance() } } @@ -37,6 +35,6 @@ class MainPagerAdapter internal constructor(fm: FragmentManager) : FragmentPager } companion object { - private const val PAGE_COUNT = 3 + private const val PAGE_COUNT = 2 } } diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/recently/MainRecentlyFragment.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/recently/MainRecentlyFragment.kt deleted file mode 100644 index 7865e916..00000000 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/recently/MainRecentlyFragment.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.iceteaviet.fastfoodfinder.ui.main.recently - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.FrameLayout -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import dagger.hilt.android.AndroidEntryPoint -import com.iceteaviet.fastfoodfinder.R -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store -import com.iceteaviet.fastfoodfinder.databinding.FragmentMainRecentlyBinding -import com.iceteaviet.fastfoodfinder.ui.custom.itemtouchhelper.OnStartDragListener -import com.iceteaviet.fastfoodfinder.ui.custom.itemtouchhelper.SimpleItemTouchHelperCallback -import com.iceteaviet.fastfoodfinder.utils.openStoreDetailActivity -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -@AndroidEntryPoint -class MainRecentlyFragment : Fragment(), OnStartDragListener { - - private val viewModel: MainRecentlyViewModel by viewModels() - - private lateinit var binding: FragmentMainRecentlyBinding - - lateinit var recyclerView: RecyclerView - lateinit var containerLayout: FrameLayout - - private var mRecentlyAdapter: RecentlyStoreAdapter? = null - private var mItemTouchHelper: ItemTouchHelper? = null - private val isFABChangeClicked = false - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - binding = FragmentMainRecentlyBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - recyclerView = binding.rvRecentlyStores - containerLayout = binding.flContainer - - setupUI() - setupEventHandlers() - setupObservers() - } - - override fun onResume() { - super.onResume() - viewModel.start() - } - - private fun setupObservers() { - viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiState.collectLatest { state -> - when (state) { - is MainRecentlyEvent.Idle -> {} - is MainRecentlyEvent.SetStores -> { - mRecentlyAdapter?.setStores(state.stores) - viewModel.markEventConsumed() - } - is MainRecentlyEvent.ShowStoreDetailView -> { - openStoreDetailActivity(requireActivity(), state.store) - viewModel.markEventConsumed() - } - } - } - } - } - } - - private fun setupUI() { - mRecentlyAdapter = RecentlyStoreAdapter(this, containerLayout) - val llm = LinearLayoutManager(context) - recyclerView.layoutManager = llm - recyclerView.adapter = mRecentlyAdapter - val decoration = DividerItemDecoration(recyclerView.context, DividerItemDecoration.VERTICAL) - recyclerView.addItemDecoration(decoration) - - mRecentlyAdapter?.let { - val callback = SimpleItemTouchHelperCallback(it) - mItemTouchHelper = ItemTouchHelper(callback) - mItemTouchHelper?.attachToRecyclerView(recyclerView) - } - } - - private fun setupEventHandlers() { - mRecentlyAdapter?.setOnItemClickListener(object : RecentlyStoreAdapter.OnItemClickListener { - override fun onClick(store: Store) { - viewModel.onStoreItemClick(store) - } - }) - } - - override fun onStartDrag(viewHolder: RecyclerView.ViewHolder) { - if (isFABChangeClicked) { - mItemTouchHelper?.startDrag(viewHolder) - } - } - - companion object { - fun newInstance(): MainRecentlyFragment { - val args = Bundle() - val fragment = MainRecentlyFragment() - fragment.arguments = args - return fragment - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/recently/MainRecentlyViewModel.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/recently/MainRecentlyViewModel.kt deleted file mode 100644 index c776fcf3..00000000 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/recently/MainRecentlyViewModel.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.iceteaviet.fastfoodfinder.ui.main.recently - -import androidx.lifecycle.ViewModel -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import javax.inject.Inject - -sealed class MainRecentlyEvent { - object Idle : MainRecentlyEvent() - data class SetStores(val stores: ArrayList) : MainRecentlyEvent() - data class ShowStoreDetailView(val store: Store) : MainRecentlyEvent() -} - -@HiltViewModel -class MainRecentlyViewModel @Inject constructor() : ViewModel() { - - private val _uiState = MutableStateFlow(MainRecentlyEvent.Idle) - val uiState: StateFlow = _uiState.asStateFlow() - - fun start() { - val stores = ArrayList() - //TODO: Load recently store from Realm - - _uiState.value = MainRecentlyEvent.SetStores(stores) - } - - fun onStoreItemClick(store: Store) { - _uiState.value = MainRecentlyEvent.ShowStoreDetailView(store) - } - - fun markEventConsumed() { - _uiState.value = MainRecentlyEvent.Idle - } -} diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/recently/RecentlyStoreAdapter.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/recently/RecentlyStoreAdapter.kt deleted file mode 100644 index 1bc31488..00000000 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/recently/RecentlyStoreAdapter.kt +++ /dev/null @@ -1,151 +0,0 @@ -package com.iceteaviet.fastfoodfinder.ui.main.recently - -import android.graphics.Color -import android.view.LayoutInflater -import android.view.MotionEvent -import android.view.View -import android.view.ViewGroup -import android.widget.FrameLayout -import android.widget.TextView -import androidx.appcompat.app.AlertDialog -import androidx.core.view.MotionEventCompat -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.snackbar.Snackbar -import com.iceteaviet.fastfoodfinder.R -import com.iceteaviet.fastfoodfinder.data.remote.store.model.Store -import com.iceteaviet.fastfoodfinder.databinding.ItemRecentlyLocationBinding -import com.iceteaviet.fastfoodfinder.ui.custom.itemtouchhelper.ItemTouchHelperAdapter -import com.iceteaviet.fastfoodfinder.ui.custom.itemtouchhelper.ItemTouchHelperViewHolder -import com.iceteaviet.fastfoodfinder.ui.custom.itemtouchhelper.OnStartDragListener - -/** - * Created by MyPC on 11/16/2016. - */ -class RecentlyStoreAdapter internal constructor(private val mDragStartListener: OnStartDragListener, frameLayout: FrameLayout) : RecyclerView.Adapter(), ItemTouchHelperAdapter { - /** - * Views Ref - */ - private lateinit var binding: ItemRecentlyLocationBinding - - private val mStoreList: MutableList - private val mContainerView: View - private var mOnItemClickListener: OnItemClickListener? = null - - init { - mStoreList = ArrayList() - mContainerView = frameLayout - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecentlyStoreViewHolder { - binding = ItemRecentlyLocationBinding.inflate(LayoutInflater.from(parent.context)) - return RecentlyStoreViewHolder(binding.root) - } - - override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean { - return false - } - - override fun onItemDismiss(position: Int) { - val store = mStoreList[position] - mStoreList.removeAt(position) - //notifyDataSetChanged(); - notifyItemRemoved(position) - Snackbar.make(mContainerView, R.string.want_undo, Snackbar.LENGTH_LONG) - .setAction(R.string.undo) { - mStoreList.add(position, store) - notifyItemInserted(position) - } - .setDuration(30000) - .show() - - } - - fun setOnItemClickListener(listener: OnItemClickListener) { - mOnItemClickListener = listener - } - - fun addStores(destinations: List) { - val pos = mStoreList.size - mStoreList.addAll(destinations) - notifyItemRangeInserted(pos, destinations.size) - } - - fun addStore(destination: Store) { - mStoreList.add(destination) - notifyItemRangeInserted(mStoreList.size, 1) - } - - fun setStores(destinations: List) { - mStoreList.clear() - mStoreList.addAll(destinations) - notifyDataSetChanged() - } - - override fun onBindViewHolder(holder: RecentlyStoreViewHolder, position: Int) { - - val store = mStoreList[position] - holder.setData(store) - // Start a drag whenever the handle view it touched - holder.itemView.setOnTouchListener { view, event -> - if (MotionEventCompat.getActionMasked(event) == MotionEvent.ACTION_DOWN) { - mDragStartListener.onStartDrag(holder) - - } - false - } - } - - override fun getItemCount(): Int { - return mStoreList.size - } - - interface OnItemClickListener { - fun onClick(store: Store) - } - - inner class RecentlyStoreViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), ItemTouchHelperViewHolder { - var txtTitle: TextView = binding.tvItemTitle - var txtAddress: TextView = binding.tvItemAddress - - init { - itemView.setOnClickListener { - val position = adapterPosition - val store = mStoreList[position] - mOnItemClickListener?.onClick(store) - } - - itemView.setOnLongClickListener { - val position = adapterPosition - AlertDialog.Builder(itemView.context) - .setTitle(R.string.delete_favourite_location) - .setMessage(R.string.are_you_sure) - .setPositiveButton(android.R.string.ok) { dialog, _ -> - mStoreList.removeAt(position) - notifyDataSetChanged() - Snackbar.make(itemView, R.string.undo, Snackbar.LENGTH_INDEFINITE).show() - dialog.dismiss() - } - .setNegativeButton(android.R.string.cancel) { dialog, _ -> - //do nothing - dialog.dismiss() - } - .setIcon(android.R.drawable.ic_dialog_alert) - .show() - true - } - } - - override fun onItemSelected() { - itemView.setBackgroundColor(Color.LTGRAY) - } - - override fun onItemClear() { - itemView.setBackgroundColor(0) - } - - fun setData(store: Store) { - txtTitle.text = store.title - txtAddress.text = store.address - } - } -} \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_main_recently.xml b/app/src/main/res/layout/fragment_main_recently.xml deleted file mode 100644 index 8ba019e4..00000000 --- a/app/src/main/res/layout/fragment_main_recently.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - diff --git a/app/src/main/res/layout/item_recently_location.xml b/app/src/main/res/layout/item_recently_location.xml deleted file mode 100644 index df12188a..00000000 --- a/app/src/main/res/layout/item_recently_location.xml +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - - - - - From d258fbf7f06966054dc54c87e04294eff827670b Mon Sep 17 00:00:00 2001 From: nhoxbypass Date: Sun, 19 Apr 2026 00:24:18 +0700 Subject: [PATCH 4/5] [Main Tab] Move "Favourite" screen to navigation drawer - Add "Favourite" item to the navigation drawer menu. - Update `MainViewModel` and `MainActivity` to handle navigation to the Favourite screen. - Remove "Favourite" tab from `MainPagerAdapter`, reducing the main pager to a single "Map" tab. - Implement `showFavouriteView` in `MainActivity` to display `MainFavouriteFragment` as a separate fragment. --- .../fastfoodfinder/ui/main/MainActivity.kt | 12 ++++++++++++ .../fastfoodfinder/ui/main/MainPagerAdapter.kt | 13 ++++--------- .../fastfoodfinder/ui/main/MainViewModel.kt | 5 +++++ app/src/main/res/menu/menu_drawer.xml | 3 +++ 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainActivity.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainActivity.kt index 0480e94c..9156269d 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainActivity.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainActivity.kt @@ -29,6 +29,7 @@ import com.iceteaviet.fastfoodfinder.core.common.ext.getInputMethodManager import com.iceteaviet.fastfoodfinder.core.common.ext.getSearchManager import com.iceteaviet.fastfoodfinder.databinding.ActivityMainBinding import com.iceteaviet.fastfoodfinder.ui.base.BaseActivity +import com.iceteaviet.fastfoodfinder.ui.main.favourite.MainFavouriteFragment import com.iceteaviet.fastfoodfinder.ui.main.search.SearchFragment import com.iceteaviet.fastfoodfinder.ui.profile.ProfileFragment import com.iceteaviet.fastfoodfinder.utils.e @@ -120,6 +121,10 @@ class MainActivity : BaseActivity(), View.OnClickListener { showSettingsView() viewModel.markEventConsumed() } + is MainEvent.NavigateToFavourite -> { + showFavouriteView() + viewModel.markEventConsumed() + } is MainEvent.ShowSearchView -> { showSearchView() viewModel.markEventConsumed() @@ -185,6 +190,10 @@ class MainActivity : BaseActivity(), View.OnClickListener { openARLiveSightActivity(this) } + private fun showFavouriteView() { + replaceFragment(MainFavouriteFragment.newInstance(), getString(R.string.favourite)) + } + private fun showSettingsView() { openSettingsActivity(this) } @@ -381,6 +390,9 @@ class MainActivity : BaseActivity(), View.OnClickListener { supportFragmentManager.popBackStack() } } + R.id.menu_action_favourite -> { + viewModel.onFavouriteMenuItemClick() + } R.id.menu_action_ar -> { viewModel.onARLiveSightMenuItemClick() } diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainPagerAdapter.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainPagerAdapter.kt index 4f76f720..ffb6c692 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainPagerAdapter.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainPagerAdapter.kt @@ -4,22 +4,17 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentPagerAdapter import com.iceteaviet.fastfoodfinder.R -import com.iceteaviet.fastfoodfinder.ui.main.favourite.MainFavouriteFragment import com.iceteaviet.fastfoodfinder.ui.main.map.MainMapFragment /** * Created by Genius Doan on 11/8/2016. */ class MainPagerAdapter internal constructor(fm: FragmentManager) : FragmentPagerAdapter(fm) { - private val pageTitles = arrayOf(R.string.map.toString(), R.string.favourite.toString()) - private val imageResId = intArrayOf(R.drawable.ic_main_map, R.drawable.ic_main_star_red) + private val pageTitles = arrayOf(R.string.map.toString()) + private val imageResId = intArrayOf(R.drawable.ic_main_map) override fun getItem(position: Int): Fragment { - return when (position) { - 0 -> MainMapFragment.newInstance() - 1 -> MainFavouriteFragment.newInstance() - else -> MainMapFragment.newInstance() - } + return MainMapFragment.newInstance() } override fun getPageTitle(position: Int): CharSequence { @@ -35,6 +30,6 @@ class MainPagerAdapter internal constructor(fm: FragmentManager) : FragmentPager } companion object { - private const val PAGE_COUNT = 2 + private const val PAGE_COUNT = 1 } } diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainViewModel.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainViewModel.kt index 78d10282..2ac77d59 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainViewModel.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainViewModel.kt @@ -25,6 +25,7 @@ sealed class MainEvent { object NavigateToLogin : MainEvent() object NavigateToAR : MainEvent() object NavigateToSettings : MainEvent() + object NavigateToFavourite : MainEvent() object ShowSearchView : MainEvent() object HideSearchView : MainEvent() object HideKeyboard : MainEvent() @@ -106,6 +107,10 @@ class MainViewModel @Inject constructor( _uiState.value = _uiState.value.copy(event = MainEvent.NavigateToSettings) } + fun onFavouriteMenuItemClick() { + _uiState.value = _uiState.value.copy(event = MainEvent.NavigateToFavourite) + } + fun onSignInMenuItemClick() { _uiState.value = _uiState.value.copy(event = MainEvent.NavigateToLogin) } diff --git a/app/src/main/res/menu/menu_drawer.xml b/app/src/main/res/menu/menu_drawer.xml index e6316de9..48f5b59a 100644 --- a/app/src/main/res/menu/menu_drawer.xml +++ b/app/src/main/res/menu/menu_drawer.xml @@ -8,6 +8,9 @@ + From 3c0806203b0408f1f7c9d5a6b6d0ee50d71dbde5 Mon Sep 17 00:00:00 2001 From: nhoxbypass Date: Sun, 19 Apr 2026 00:42:15 +0700 Subject: [PATCH 5/5] [Main Tab] Remove `MainFragment` and set `MainMapFragment` as the default view - Remove `MainFragment`, `MainPagerAdapter`, and their associated layout `fragment_main.xml`. - Update `MainActivity` to host `MainMapFragment` directly in the fragment placeholder. - Remove top margin from `map_placeholder` in `fragment_main_map.xml`. --- .../fastfoodfinder/ui/main/MainActivity.kt | 3 +- .../fastfoodfinder/ui/main/MainFragment.kt | 91 ------------------- .../ui/main/MainPagerAdapter.kt | 35 ------- app/src/main/res/layout/fragment_main.xml | 36 -------- app/src/main/res/layout/fragment_main_map.xml | 3 +- 5 files changed, 3 insertions(+), 165 deletions(-) delete mode 100644 app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainFragment.kt delete mode 100644 app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainPagerAdapter.kt delete mode 100644 app/src/main/res/layout/fragment_main.xml diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainActivity.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainActivity.kt index 9156269d..f599ecbb 100644 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainActivity.kt +++ b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainActivity.kt @@ -30,6 +30,7 @@ import com.iceteaviet.fastfoodfinder.core.common.ext.getSearchManager import com.iceteaviet.fastfoodfinder.databinding.ActivityMainBinding import com.iceteaviet.fastfoodfinder.ui.base.BaseActivity import com.iceteaviet.fastfoodfinder.ui.main.favourite.MainFavouriteFragment +import com.iceteaviet.fastfoodfinder.ui.main.map.MainMapFragment import com.iceteaviet.fastfoodfinder.ui.main.search.SearchFragment import com.iceteaviet.fastfoodfinder.ui.profile.ProfileFragment import com.iceteaviet.fastfoodfinder.utils.e @@ -78,7 +79,7 @@ class MainActivity : BaseActivity(), View.OnClickListener { mNavigationView.menu.getItem(0).isChecked = true mNavigationView.setCheckedItem(R.id.menu_action_map) val fragmentManager = supportFragmentManager - fragmentManager.beginTransaction().replace(R.id.fl_fragment_placeholder, MainFragment.newInstance()).commit() + fragmentManager.beginTransaction().replace(R.id.fl_fragment_placeholder, MainMapFragment.newInstance()).commit() } override fun onPostCreate(savedInstanceState: Bundle?) { diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainFragment.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainFragment.kt deleted file mode 100644 index 8a006ec7..00000000 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainFragment.kt +++ /dev/null @@ -1,91 +0,0 @@ -package com.iceteaviet.fastfoodfinder.ui.main - - -import javax.inject.Inject -import dagger.hilt.android.AndroidEntryPoint -import android.graphics.BlendMode -import android.graphics.BlendModeColorFilter -import android.graphics.Color -import android.os.Build -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.annotation.RequiresApi -import androidx.core.content.ContextCompat -import androidx.fragment.app.Fragment -import androidx.viewpager.widget.ViewPager -import com.google.android.material.tabs.TabLayout -import com.iceteaviet.fastfoodfinder.R -import com.iceteaviet.fastfoodfinder.databinding.FragmentMainBinding -import com.iceteaviet.fastfoodfinder.utils.d - -/** - * A simple [Fragment] subclass. - */ -@AndroidEntryPoint -class MainFragment : Fragment() { - /** - * Views Ref - */ - private lateinit var binding: FragmentMainBinding - - private lateinit var mTabLayout: TabLayout - private lateinit var mViewPager: ViewPager - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle?): View { - // Inflate the layout for this fragment - binding = FragmentMainBinding.inflate(inflater, container, false) - return binding.root - } - - @RequiresApi(Build.VERSION_CODES.Q) - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - mTabLayout = binding.tabLayout - mViewPager = binding.viewPager - - val mPagerAdapter = MainPagerAdapter(childFragmentManager) - mViewPager.adapter = mPagerAdapter - mViewPager.offscreenPageLimit = 1 - mTabLayout.setupWithViewPager(mViewPager) - - - for (i in 0 until mTabLayout.tabCount) { - mTabLayout.getTabAt(i)?.text = null - mTabLayout.getTabAt(i)?.setIcon(mPagerAdapter.getIcon(i)) - } - - mTabLayout.getTabAt(0)?.icon?.colorFilter = BlendModeColorFilter(Color.WHITE, BlendMode.SRC_IN) - - - mTabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { - override fun onTabSelected(tab: TabLayout.Tab) { - val tabItemColor = Color.WHITE - tab.icon?.colorFilter = BlendModeColorFilter(tabItemColor, BlendMode.SRC_IN) - } - - override fun onTabUnselected(tab: TabLayout.Tab) { - val tabItemColor = ContextCompat.getColor(context!!, R.color.colorYouTubeDark) - tab.icon?.colorFilter = BlendModeColorFilter(tabItemColor, BlendMode.SRC_IN) - } - - override fun onTabReselected(tab: TabLayout.Tab) { - d(TAG, "onTabReselected") - } - }) - } - - companion object { - private val TAG = MainFragment::class.java.simpleName - - fun newInstance(): MainFragment { - val args = Bundle() - - val fragment = MainFragment() - fragment.arguments = args - return fragment - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainPagerAdapter.kt b/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainPagerAdapter.kt deleted file mode 100644 index ffb6c692..00000000 --- a/app/src/main/java/com/iceteaviet/fastfoodfinder/ui/main/MainPagerAdapter.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.iceteaviet.fastfoodfinder.ui.main - -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.FragmentPagerAdapter -import com.iceteaviet.fastfoodfinder.R -import com.iceteaviet.fastfoodfinder.ui.main.map.MainMapFragment - -/** - * Created by Genius Doan on 11/8/2016. - */ -class MainPagerAdapter internal constructor(fm: FragmentManager) : FragmentPagerAdapter(fm) { - private val pageTitles = arrayOf(R.string.map.toString()) - private val imageResId = intArrayOf(R.drawable.ic_main_map) - - override fun getItem(position: Int): Fragment { - return MainMapFragment.newInstance() - } - - override fun getPageTitle(position: Int): CharSequence { - return pageTitles[position] - } - - override fun getCount(): Int { - return PAGE_COUNT - } - - fun getIcon(position: Int): Int { - return imageResId[position] - } - - companion object { - private const val PAGE_COUNT = 1 - } -} diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml deleted file mode 100644 index b9637cc3..00000000 --- a/app/src/main/res/layout/fragment_main.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_main_map.xml b/app/src/main/res/layout/fragment_main_map.xml index f37abe63..e0326461 100644 --- a/app/src/main/res/layout/fragment_main_map.xml +++ b/app/src/main/res/layout/fragment_main_map.xml @@ -13,8 +13,7 @@ + android:layout_height="wrap_content" />