Updates skills (2026-07-06 08:06)

This commit is contained in:
android-devrel-github-bot
2026-07-06 08:06:29 +00:00
parent 07302ca15e
commit 79bee216c4
43 changed files with 3457 additions and 364 deletions
+2 -2
View File
@@ -6,7 +6,7 @@ description: Upgrades, or migrates, an Android project to use Android Gradle Plu
license: Complete terms in LICENSE.txt
metadata:
author: Google LLC
last-updated: '2026-06-03'
last-updated: '2026-06-25'
keywords:
- Android Gradle Plugin 9
- AGP 9
@@ -51,7 +51,7 @@ 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](https://developer.android.com/build/migrate-to-built-in-kotlin) for detailed information.
See [the guide](references/android/build/migrate-to-built-in-kotlin.md) for detailed information.
### Step 3. Migrate to the new AGP DSL
@@ -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 "<KOTLIN_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
}
```
-270
View File
@@ -1,270 +0,0 @@
---
name: camera1-to-camerax
description: Use this skill to migrate legacy Android camera implementations (Camera1
or raw Camera2 APIs) to CameraX. CameraX is a lifecycle-aware Jetpack library built
on top of Camera2 that resolves camera rotation issues and handles device dependencies.
license: Complete terms in LICENSE.txt
metadata:
author: Google LLC
last-updated: '2026-05-06'
keywords:
- Android
- CameraX
- Camera1 Migration
- Jetpack Compose
- Dependencies
- Image Capture
- Lifecycle
- PreviewView
---
## Step 0: Add Dependencies
Check for and add the required CameraX dependencies. Use version 1.3.0 or higher
for interoperability, or version 1.5.0 or higher for Compose extensions.
If you are using a Version Catalog (`libs.versions.toml`), add the following:
```kotlin
[versions]
camerax = "<minimum_version_needed>"
[libraries]
androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" }
androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" }
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" }
androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" }
androidx-camera-compose = { group = "androidx.camera", name = "camera-compose", version.ref = "camerax" }
```
<br />
And in your `build.gradle.kts` (or `build.gradle`):
```kotlin
implementation(libs.androidx.camera.core)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
implementation(libs.androidx.camera.compose)
```
<br />
Without a Version Catalog, fall back to these standard Gradle dependencies:
```kotlin
implementation "androidx.camera:camera-core:<minimum_version_needed>"
implementation "androidx.camera:camera-camera2:<minimum_version_needed>"
implementation "androidx.camera:camera-lifecycle:<minimum_version_needed>"
implementation "androidx.camera:camera-view:<minimum_version_needed>"
implementation "androidx.camera:camera-compose:<minimum_version_needed>"
```
<br />
## Step 1: Remove Legacy Implementation
1. Delete all `android.hardware.Camera` instances.
2. Delete `SurfaceView` and `SurfaceHolder.Callback` implementations (`surfaceCreated`, `surfaceChanged`, `surfaceDestroyed`).
3. Remove custom lifecycle handling that opens or releases the camera in `onResume` or `onPause`.
4. Remove manual matrix calculations for orientation.
## Step 2: Initialize ProcessCameraProvider
Request the `ProcessCameraProvider` and bind use cases to the Activity or
Fragment lifecycle.
```kotlin
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(context, lifecycleOwner) {
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
val cameraSelector = CameraSelector.Builder()
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
.build()
val preview = Preview.Builder().build()
val imageCapture = ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
.build()
cameraProvider.unbindAll() // Unbind before rebinding
val camera = cameraProvider.bindToLifecycle(
lifecycleOwner,
cameraSelector,
preview,
imageCapture
)
val cameraControl = camera.cameraControl
}, ContextCompat.getMainExecutor(context)
)
}
```
<br />
## Step 3: Implement the Preview \& Tap-to-Focus
Choose exactly one of the following patterns based on the app's UI toolkit:
### Option A: For Android Views (XML Legacy)
Use `androidx.camera.view.PreviewView`.
**1. Set up preview**:
```kotlin
preview.setSurfaceProvider(previewView.surfaceProvider)
```
<br />
**2. Handle tap-to-focus**:
```kotlin
val factory = previewView.meteringPointFactory
val point = factory.createPoint(x, y) // x, y from touch event
val action = FocusMeteringAction.Builder(point, FocusMeteringAction.FLAG_AF).build()
cameraControl?.startFocusAndMetering(action)
```
<br />
### Option B: For Jetpack Compose
Use `androidx.camera.compose.CameraXViewfinder`.
**1. Set up preview and SurfaceRequest**:
```kotlin
var surfaceRequest by remember { mutableStateOf<SurfaceRequest?>(null) }
val preview = remember {
Preview.Builder().build().apply {
setSurfaceProvider { request -> surfaceRequest = request }
}
}
```
<br />
**2. Render viewfinder**:
```kotlin
surfaceRequest?.let { request ->
CameraXViewfinder(
surfaceRequest = request,
coordinateTransformer = coordinateTransformer,
modifier = Modifier
)
}
```
<br />
**3. Handle tap-to-focus in Compose**:
```kotlin
// Inside your tap gesture handler...
val surfaceCoords = with(coordinateTransformer) { offset.transform() }
val factory = SurfaceOrientedMeteringPointFactory(
request.resolution.width.toFloat(),
request.resolution.height.toFloat()
)
val point = factory.createPoint(surfaceCoords.x, surfaceCoords.y)
val action = FocusMeteringAction.Builder(point, FocusMeteringAction.FLAG_AF).build()
cameraControl?.startFocusAndMetering(action)
```
<br />
**4. Update target rotation for Compose**:
```kotlin
LaunchedEffect(configuration) {
if (!view.isInEditMode) {
val rotation = view.display?.rotation ?: Surface.ROTATION_0
imageCapture.targetRotation = rotation
preview.targetRotation = rotation
}
}
```
<br />
## Step 4: Capture Photo
Use the `ImageCapture` use case to take the picture. The `ImageProxy` handles
rotation directly.
```kotlin
imageCapture.takePicture(
cameraExecutor,
object : ImageCapture.OnImageCapturedCallback() {
override fun onCaptureSuccess(image: ImageProxy) {
val buffer = image.planes[0].buffer
val bytes = ByteArray(buffer.remaining())
buffer.get(bytes)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
// Adjust rotation natively via ImageProxy
val matrix = Matrix()
matrix.postRotate(image.imageInfo.rotationDegrees.toFloat())
if (lensFacing == CameraSelector.LENS_FACING_FRONT) {
matrix.postScale(-1f, 1f) // Mirror for front camera
}
val rotatedBitmap = Bitmap.createBitmap(
bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true
)
// MUST close proxy
image.close()
}
override fun onError(exception: ImageCaptureException) {
Log.e("CameraX", "Capture failed: ${exception.message}", exception)
}
}
)
```
<br />
## Step 5: Switch Cameras
To flip between front and rear cameras, change the `CameraSelector` and
re-trigger the `ProcessCameraProvider` logic.
```kotlin
lensFacing = if (lensFacing == CameraSelector.LENS_FACING_BACK) {
CameraSelector.LENS_FACING_FRONT
} else {
CameraSelector.LENS_FACING_BACK
}
```
<br />
## Constraints
- **Don't manage the camera lifecycle manually** : Bind the camera to a `LifecycleOwner` through the `ProcessCameraProvider`. Avoid manual camera open or close logic in `onResume` or `onPause`.
- **Don't calculate focus matrices manually** : `MeteringPointFactory` handles coordinate transformations, including device rotation offsets. Avoid custom matrix implementations.
- **Don't forget to close the ImageProxy** : Remember to invoke `image.close()` in the capture callback. Skipping this call locks the capture pipeline and interrupts subsequent photos.
- **Don't wrap `PreviewView` in `AndroidView` for Compose code** : For Compose UI layouts, use `CameraXViewfinder`. Compiling `PreviewView` in an `AndroidView` is an old fallback option that introduces resizing issues.
+125
View File
@@ -0,0 +1,125 @@
---
name: camerax
description: Provide technical guidance for Android camera development with CameraX.
Use when implementing camera features, handling asynchronous recording lifecycles,
wiring low-level hardware interop using CameraX, or integrating ML Kit or Media3
effects.
license: Complete terms in LICENSE.txt
metadata:
author: Google LLC
last-updated: '2026-07-02'
keywords:
- recipe
- Android
- Camera
- Camera1
- Camera2
- CameraX
- migration
- Compose
- guide
- dependencies
- PreviewView
- CameraXViewfinder
- ImageCapture
- VideoCapture
- ImageAnalysis.
---
This skill provides procedural guidance and standard patterns for building
camera applications on Android, with a focus on CameraX, including its
`Camera2Interop` utilities, and Media3 integrations.
## Core workflows
### Handling immutable API patterns
Various Android camera and media APIs, especially CameraX `VideoCapture`, use a
**fluent, immutable builder-like pattern** where methods return a new instance.
Failing to reassign these results in settings, such as audio, being ignored.
**Pattern: Reassignment is required**
```kotlin
// WRONG
run {
val pending = recorder.prepareRecording(context, opts)
pending.withAudioEnabled() // This returns a new instance which is ignored
val active = pending.start(exec, listener)
}
// CORRECT
run {
val pending = recorder.prepareRecording(context, opts)
.withAudioEnabled() // Chaining works
val active = pending.start(exec, listener)
}
// ALSO CORRECT
run {
var pending = recorder.prepareRecording(context, opts)
pending = pending.withAudioEnabled() // Reassignment
val active = pending.start(exec, listener)
}
```
<br />
See [immutability](references/immutability.md) for a list of affected classes.
### Comprehensive feature blueprinting
For multi-step features that involve multiple files and hardware-level wiring,
follow the [Structural Blueprinting](references/expert-blueprints.md) approach to avoid
system timeouts. Such complex features include:
- **Manual controls** : Break down into the `ViewModel` state, the controller layer, and the `Camera2Interop` wiring in the session.
- **RAW capture**: Separate JPEG and RAW output configurations into discrete build steps.
- **Custom effects** : Prefer `Media3Effect` or `SurfaceProcessor` over manual OpenGL pipelines unless absolute performance is required.
- **Low-light** : See [low-light](references/low-light.md) for Night Mode and LLB guidance.
- **Foldables** : See [foldables](references/foldables.md) for handling dynamic postures and hinge states.
- **XR, AR, and VR** : See [xr](references/xr.md) for spatial tracking, passthrough synchronization, and latency guardrails.
- **Thermals and power** : See [thermals](references/thermals.md) for managing `StreamUseCase` optimizations and `PowerManager` thermal states.
- **Testing and mocking** : See [testing](references/testing.md) for using `FakeCameraConfig`, handling asynchronous lifecycles, and validating analysis pipelines.
- **ML Kit spatial analysis** : See [mlkit-spatial](references/mlkit-spatial.md) for coordinate mapping, rotation logic, and mirrored lens handling.
- **Wear OS camera remote** : See [wear-os](references/wear-os.md) for circular UI constraints, Data Layer API syncing, and remote trigger logic.
See [expert-blueprints](references/expert-blueprints.md) for step-by-step guides.
### API discovery
Always use higher-level abstractions instead of low-level manual wiring:
- **Analysis** : Use `MlKitAnalyzer` instead of manual `ImageAnalysis.Analyzer`.
- **Filters and effects** : Use `Media3Effect` for standard post-processing.
- **Multi-camera** : Use `ConcurrentCamera` APIs for dual-stream setups.
See [modern-apis](references/modern-apis.md) for current recommendations.
### Code quality and architectural rules
Adhere to the following Android ecosystem standard patterns when building your
camera implementations:
- **Testing, fakes over mocks** : Avoid mocking libraries like `Mockito`, especially for multi-step CameraX interfaces like `ImageProxy`. Build "Fakes" to verify state rather than unreliable implementation details.
- **Google Truth assertions** : Use `assertThat` over standard `JUnit` assertions like `assertEquals` for improved readability.
- **Explicit test runners** : Always define an explicit `@RunWith` for test classes to ensure the CI environment executes them correctly.
- **Semantic UI merging** : When building custom camera controls in Compose, such as a button with an `Icon` and `Text`, use `semantics {
mergeDescendants = true }` to ensure screen readers announce them as a single, coherent unit.
## Hardware and device diversity
Camera apps run on a wide variety of hardware, from mobile phones and
foldables to tablets, laptops, and even smart appliances. Have consideration
for the specific hardware the app is running on.
- **Form factors**: Account for screen size and orientation changes on foldables and tablets.
- **Multi-camera arrays**: Some devices have a rear-facing camera and a front-facing camera. Other devices have multiple rear-facing cameras, such as wide-angle and telephoto lenses.
- **Feature parity**: Features like flash or auto-focus behave differently across hardware. For example, CameraX handles both physical flash, back, and screen-based flash, front, and both must be considered when implementing flash functionality.
## Common pitfalls
- **Asynchronous lifecycles** : Check `isRecording` state before attempting to stop or pause. Handle `VideoRecordEvent.Start` for UI state updates, not just the initial call.
- **Thread safety**: Camera callbacks often run on background executors. Dispatch UI updates on the main thread.
- **Permission handling** : Check `CAMERA` permission; check for `RECORD_AUDIO` specifically when enabling audio in `VideoCapture`.
@@ -0,0 +1,58 @@
Complex camera features often fail due to "Agent Stall" or timeouts when
attempted in a single turn. Use these blueprints to break tasks into manageable
phases.
## Manual controls
### Phase one: ViewModel and state
1. Define a `ManualSettings` data class.
2. Add a `MutableStateFlow<ManualSettings>` to your `CameraViewModel`.
3. Implement the Jetpack Compose UI with sliders and switches to update this flow.
### Phase two: Controller wiring
1. In `CameraController.kt`, create a new function `updateManualSettings(settings: ManualSettings)`.
2. Map these settings into the `CameraSystem` layer.
### Phase three: CameraX `Camera2Interop` wiring
1. In `CameraSession.kt`, use the `Camera2Interop.Extender` utility to access Camera2 capture request keys.
2. Apply the hardware keys:
- `CaptureRequest.SENSOR_SENSITIVITY` to set ISO sensitivity.
- `CaptureRequest.SENSOR_EXPOSURE_TIME` to set exposure time.
- `CaptureRequest.LENS_FOCUS_DISTANCE` to set focus distance.
3. **Critical** : If manual exposure is active, set `CaptureRequest.CONTROL_AE_MODE` to `CameraMetadata.CONTROL_AE_MODE_OFF`. ---
## RAW and JPEG capture
### Phase one: Output configuration
1. Verify device support for RAW capture using `CameraInfo`.
2. Configure `ImageCapture.Builder` with `OUTPUT_FORMAT_RAW_JPEG` or `OUTPUT_FORMAT_RAW`.
### Phase two: Implementation
1. Provide `ImageCapture.OutputFileOptions` for the target storage locations.
2. Invoke `takePicture`. CameraX internally manages `DngCreator` to wrap RAW data with the required `CameraCharacteristics` and `CaptureResult` metadata.
*** ** * ** ***
## Apply image effects
### Phase one: Effects selection
1. Use the `androidx.media3:media3-effect` dependency.
2. Use `RgbFilter` or `HslAdjustment` for standard color grading.
### Phase two: Application
1. Configure `Composition.Builder` or `MediaItem.Builder` with the list of effects.
2. Inject the list of effects into the CameraX `Recorder` or `Preview` using the `setEffects` method.
*** ** * ** ***
## Low-light capture
For guidance on Night Mode Extensions and Low Light Boost,
[low-light.md](https://developer.android.com/agents/skills/camera/camerax/references/low-light).
+83
View File
@@ -0,0 +1,83 @@
Foldable devices introduce unique challenges for camera applications, including
dynamic layout changes, multiple display orientations, and physical device
postures, such as tabletop and book modes.
## Manage fold states and postures
| State | Posture | User interaction | Implementation goal |
|---|---|---|---|
| `FLAT` | Standard | Full screen preview | Conventional mobile phone layout. |
| `HALF_OPENED` | Tabletop | Lower half for controls | Split-screen layout, viewfinder on top, controls on bottom. |
| `HALF_OPENED` | Book | Side-by-side | Viewfinder on one panel, gallery and controls on the other. |
*** ** * ** ***
## Follow the implementation guide
### Detect posture changes
Use the Jetpack WindowManager library to observe the device's hinge state and
fold layout.
```kotlin
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
windowInfoTracker.windowLayoutInfo(activity)
.collect { layoutInfo ->
val displayFeature = layoutInfo.displayFeatures
.filterIsInstance<FoldingFeature>()
.firstOrNull()
updateCameraLayout(displayFeature)
}
}
}
```
<br />
### Handle tabletop mode
In Tabletop mode, horizontal fold, you should move the viewfinder to the top
half of the screen and the controls to the bottom half to prevent the user from
seeing a "bent" image.
- **Identify orientation:** Check `FoldingFeature.orientation`.
- **Calculate geometry:** Use `FoldingFeature.bounds` to identify the hinge's physical location on the screen.
- **Update UI:** Apply padding or constraints to move the `PreviewView` above the hinge.
### Coordinate mapping and `Viewport`
When the UI layout changes due to a fold, you **must** update the `Viewport` to
ensure that tap-to-focus and image capture coordinates remain accurate.
```kotlin
val viewport = ViewPort.Builder(Rational(viewfinder.width, viewfinder.height), display.rotation)
.setScaleType(ViewPort.FILL_CENTER)
.build()
val useCaseGroup = UseCaseGroup.Builder()
.addUseCase(preview)
.setViewPort(viewport)
.build()
```
<br />
### Rear display mode
Some foldables allow using the rear camera with the cover display while the
device is unfolded.
- **Verification:** If available through OEM SDKs or Android 14 (API level 34) or higher, check `DeviceState.REAR_DISPLAY_STATE`.
- **Logic:** Handle preview detachment and reattachment on different display surfaces with varying aspect ratios.
*** ** * ** ***
## Foldable pitfalls
- **Hinge distortion:** Don't span the camera preview across a hinge in `HALF_OPENED` state.
- **Physical orientation:** The camera sensor's physical orientation relative to the screen often changes when you fold or unfold the device. Always rely on `CameraInfo.getSensorRotationDegrees`.
- **Latency:** Rebinding `UseCase` objects during a fold event is expensive. Use the internal scaling of `PreviewView` before performing a full `bindToLifecycle` reconfiguration.
+60
View File
@@ -0,0 +1,60 @@
Many Android APIs are designed with immutability in mind to prevent race
conditions in async environments. However, this often trips up developers used
to mutable builder patterns.
## Common immutable classes
The following classes use fluent APIs that **return a new instance**. You must
reassign the variable.
| Class | Methods that return a new instance | Result if not reassigned |
|---|---|---|
| `PendingRecording` | `withAudioEnabled`, `asPersistentRecording` | Audio isn't recorded. |
| `ImageCapture.Builder` | `setTargetRotation`, `setTargetResolution` | The output has the wrong orientation. |
| `Recorder.Builder` | `setQualitySelector`, `setExecutor` | The recording uses the default quality. |
| `Viewport.Builder` | `setScaleType`, `setLayoutDirection` | The viewfinder is stretched. |
## Use standard patterns
### CameraX video recording
To set up video recording, use the following code:
```kotlin
// WRONG
run {
val pending = recorder.prepareRecording(context, opts)
pending.withAudioEnabled() // This returns a new instance which is ignored
val active = pending.start(exec, listener)
}
// CORRECT
run {
val pending = recorder.prepareRecording(context, opts)
.withAudioEnabled() // Chaining works
val active = pending.start(exec, listener)
}
// ALSO CORRECT
run {
var pending = recorder.prepareRecording(context, opts)
pending = pending.withAudioEnabled() // Reassignment
val active = pending.start(exec, listener)
}
```
<br />
### Viewport construction
To set up the viewport, use the following code:
```kotlin
val viewport = ViewPort.Builder(Rational(width, height), displayRotation)
.setScaleType(ViewPort.FILL_CENTER)
.build()
```
<br />
+124
View File
@@ -0,0 +1,124 @@
This guide covers implementing low-light features using **Night mode
extensions** and **Low Light Boost (LLB)**.
## Choosing the right tool
| Feature | Used for | Implementation | UX impact |
|---|---|---|---|
| **Night mode** | High-quality stills | `ExtensionsManager` | Possibly requires user to hold still for several seconds. |
| **LLB, AE mode** | Real-time preview or video | `Camera2Interop`, CameraX utility | Hardware drops the frame rate to increase brightness. |
| **LLB, Play services** | Real-time preview and video | `SurfaceProcessor` | Software-based brightening; maintains a higher frame rate. |
*** ** * ** ***
## Night mode extension
CameraX Extensions provide access to the device's built-in computational
photography pipeline.
### Basic setup
To set up the extension, initialize the extension manager:
```kotlin
// Use ListenableFuture.await() extension function for coroutine support
val extensionsManager = ExtensionsManager.getInstanceAsync(context, cameraProvider).await()
if (extensionsManager.isExtensionAvailable(cameraSelector, ExtensionMode.NIGHT)) {
val nightSelector = extensionsManager.getExtensionEnabledCameraSelector(
cameraSelector, ExtensionMode.NIGHT
)
cameraProvider.bindToLifecycle(lifecycleOwner, nightSelector, imageCapture, preview)
}
```
<br />
### Comprehensive features
- **Image postview** : Display a low-resolution image immediately while the multi-frame processing occurs.
```kotlin
val imageCapture = ImageCapture.Builder()
.setPostviewEnabled(true)
.build()
```
- **Extension strength** : Let users control the intensity of the night effect.
```kotlin
// Set the strength of the active extension (e.g. NIGHT mode intensity)
val extensionsManager = ExtensionsManager.getInstanceAsync(context, cameraProvider).await()
val extensionsControl = extensionsManager.getCameraExtensionsControl(camera.cameraControl)
extensionsControl?.setExtensionStrength(strength)
```
- **Capture progress** : Show a UI progress bar for long exposures.
```kotlin
// Use the suspend extension function for takePicture to avoid callback boilerplate
try {
val result = imageCapture.takePicture(outputOptions)
// Use result.savedUri or other fields
} catch (e: ImageCaptureException) {
// Handle capture failure
}
```
*** ** * ** ***
## Low-light boost
LLB is designed for preview and video streams where you prefer high frame rates.
### AE mode
The built-in CameraX way to prioritize brightness. It modifies the hardware's
auto-exposure algorithm.
- **Activation** : Use `CameraControl.enableLowLightBoostAsync`.
- **Implementation** :
```kotlin
// Enable Low Light Boost (LLB) natively in CameraX 1.4+
camera.cameraControl.enableLowLightBoostAsync(true)
```
- **Monitoring** : Observe `CameraInfo.lowLightBoostState` to track when the hardware actively applies the enhancement.
### Google Play services LLB
It's a multi-step implementation that uses a session-based `SurfaceProcessor`.
**Dependency** : `com.google.android.gms:play-services-camera-low-light-boost`
To implement Google Play services LLB, follow these core steps:
1. **Initialize client** : `val client = LowLightBoost.getClient`.
2. **Implement `SurfaceProcessor`** :
- **Manage session** : Call `client.createSession`.
- **Forward required metadata** : Observe the camera's `TotalCaptureResult` stream and forward every result to the session: `session.processCaptureResult`.
- **Provide surface** : Get the input surface from the session, `session.getCameraSurface`, and provide it to the camera's `SurfaceRequest`.
- **Lifecycle** : Release the session, `session.release`, when the processor is closed or the `SurfaceRequest` completes.
3. **Wire using `CameraEffect`** :
```kotlin
val effect = SimpleCameraEffect(
CameraEffect.PREVIEW or CameraEffect.VIDEO_CAPTURE,
executor,
llbSurfaceProcessor
) { throw it }
// Add to UseCaseGroup
val useCaseGroup = UseCaseGroup.Builder()
.addUseCase(preview)
.addUseCase(videoCapture)
.addEffect(effect)
.build()
```
4. **Scene detection** : Use `session.setSceneDetectorCallback` to receive `boostStrength` updates for real-time UI indicators.
*** ** * ** ***
## Implementation notes
- **Thread safety** : Always handle `ExtensionsManager` and `LowLightBoostClient` initialization asynchronously.
- **FPS trade-offs**: AE mode LLB often drops the frame rate significantly to increase brightness.
- **Compatibility** : Extensions, Night Mode, possibly conflict with `ConcurrentCamera`. Always verify support before binding.
@@ -0,0 +1,75 @@
When you use ML Kit for features such as face mesh, object detection, or pose
detection, the most common failure point is the coordinate disparity between the
analysis image and the viewfinder UI.
## The mapping mindset
| Dimension | Analysis frame | Viewfinder UI on the screen |
|---|---|---|
| Resolution | Fixed, such as 640x480 | Dynamic, such as 1080x2400 |
| Rotation | 0° for the raw buffer | 90° or 270° in portrait or landscape mode |
| Origin | Top-left of buffer at (0,0) | Top-left of screen at (0,0) |
*** ** * ** ***
## Follow the implementation guide
### Coordinate transformation matrix
Android provides the `Viewport` and `UseCaseGroup` APIs to calculate the
transformation matrix automatically. **Don't** calculate aspect ratio scaling
manually.
```kotlin
val transform = previewView.viewPort?.let { viewPort ->
// Use CameraX's built-in coordinate mapper
viewPort.getTransformationMatrix(imageProxy.imageInfo.rotationDegrees)
}
```
<br />
### Handling the "double rotation" bug
ML Kit results, bounding boxes, are relative to the **rotated buffer**. If the
device is in portrait, the buffer is often 480x640, landscape, but the screen
is 1080x1920.
To map the coordinates, use the following workflow:
1. Query `imageProxy.imageInfo.rotationDegrees`.
2. Pass this rotation to the ML Kit `InputImage`.
3. Use the `MappingUtils.transformRect` method to map the result `Rect` to the screen.
### Face mesh and pose normalization
For high-precision spatial analysis, for example, "Is the user's hand at a
specific screen button?", use **normalized coordinates from 0.0 to 1.0**.
```kotlin
// Example: Converting a Pose landmark to a Screen Coordinate
val screenX = landmark.position.x / analysisWidth * screenWidth
val screenY = landmark.position.y / analysisHeight * screenHeight
```
<br />
**Warning** : Always account for **mirrored lenses** . If the `LENS_FACING_FRONT`
is used, you must flip the X-coordinate: `actualX = screenWidth - screenX`.
### Overlays and canvas clipping
Use a custom `GraphicOverlay` view on top of the `PreviewView`.
- **Buffer lock** : Ensure your `GraphicOverlay` clears its canvas every time a new `ImageAnalysis` frame is processed to prevent "ghosting" of bounding boxes.
*** ** * ** ***
## Spatial pitfalls
- **The "stretched box" bug** : Caused by assuming the Analysis Frame aspect ratio, 4:3, matches the screen aspect ratio, 21:9. Use `PreviewView.SCALE_TYPE_FILL_CENTER` and map coordinates accordingly.
- **Latency** : If ML processing exceeds 50 ms, the bounding box trails behind the user's face.
- **Fix** : Use `ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST` to avoid queuing stale frames.
- **Sensor vs. display rotation** : On some tablets, the sensor is mounted horizontally. Always query `display.rotation` and `cameraInfo.sensorRotationDegrees`.
+42
View File
@@ -0,0 +1,42 @@
Always prefer these various abstractions over legacy Camera2 or early CameraX
implementations.
## Compare APIs
| Use case | Legacy or verbose way | Recommendation |
|---|---|---|
| QR or face scanning | `ImageAnalysis.Analyzer` and manual ByteBuffer math | **`MlKitAnalyzer`**, which automates coordinate mapping and multi-format support |
| Post-processing | Custom OpenGL shaders or `SurfaceTexture` | **`Media3Effect`** for composable, declarative effects |
| Dual camera | Manual binding of two UseCases | **`ConcurrentCamera`**, which provides built-in support in CameraX 1.3 and higher |
| High dynamic range | Manual bit-depth and profile config | **`DynamicRange`** , which uses `DYNAMIC_RANGE_HLG10` or `SDR` |
| Zoom and focus | `Camera2Interop` for CameraX-to-Camera2 mapping | **`CameraControl.setZoomRatio`** or **`setLinearZoom`** |
## Hardware awareness
Modern APIs abstract away the complexity of hardware diversity.
- **`CameraSelector`** : Use `DEFAULT_BACK_CAMERA` or `DEFAULT_FRONT_CAMERA` instead of hardcoding camera IDs. Use `filter` if you need specific lens capabilities.
- **Extensions** : Before enabling advanced modes, such as night, bokeh, and face retouch, use `ExtensionsManager` to query whether the device supports them.
- **Foldables** : Observe `Lifecycle` and `Viewport` updates to handle posture changes, like a half-opened posture, on foldable devices.
## Required dependencies
Add the following dependencies to your `libs.versions.toml` file:
# CameraX ML Kit
androidx-camera-mlkit-vision = { group = "androidx.camera", name =
"camera-mlkit-vision", version.ref = "camerax" }
# Media3 effects
androidx-media3-effect = { group = "androidx.media3", name = "media3-effect",
version.ref = "media3" }
# Camera extensions
androidx-camera-extensions = { group = "androidx.camera", name =
"camera-extensions", version.ref = "camerax" }
Refer to the official [CameraX Release Notes](https://developer.android.com/jetpack/androidx/releases/camera) for the
stable versions.
+69
View File
@@ -0,0 +1,69 @@
Automated testing for camera features is notoriously difficult because you
can't easily mock physical hardware, lighting, or motion. This guide provides
patterns for reliable, hermetic camera tests.
## Develop a testing mindset
| Challenge | Conventional approach | Camera technical approach |
|---|---|---|
| **Frameworks** | `Mockito` or `MockK` | **Fakes over mocks** |
| **Assertions** | `assertEquals`, `assertTrue` | **Google Truth, `assertThat`** |
| **Environment** | Implied environments | **Explicit `@RunWith` annotations** |
| **Async operations** | `Thread.sleep` | **Explicit `timeoutMillis` or `IdlingResource`** |
*** ** * ** ***
## Follow the implementation guide
### Fakes over mocks
**Don't use Mockito.** Relying on mocks for complex, rapidly changing interfaces
like `ImageProxy` or `CameraInfo` makes tests brittle. Instead, build "Fake"
implementations that verify state rather than behavior.
```kotlin
// Create a Fake ImageProxy for ML Testing (Fakes over Mocks)
val fakeImage = FakeImageProxy(w = 640, h = 480)
// Feed the fake buffer into your analyzer
```
<br />
### Mock camera capabilities
Use `FakeAppConfig` from `androidx.camera:camera-testing` to simulate specific
hardware constraints in tests, such as a device without a flash.
```kotlin
// Use awaitInstance() extension function for coroutine-based provider retrieval
val cameraProvider = ProcessCameraProvider.awaitInstance(context)
```
<br />
### Use Truth assertions
Use Google Truth, `assertThat`, instead of standard JUnit assertions. It
provides more readable assertion chains and useful failure messages.
### Test asynchronous lifecycles
Camera initialization is asynchronous. Use `IdlingResource` to ensure
your test waits for the `UseCase` to be bound before asserting.
To test asynchronous lifecycles, use the following pattern:
1. Wrap the `ProcessCameraProvider` initialization in a `CountDownLatch` or `IdlingResource`.
2. Assert only after the `cameraControl` instance is non-null.
*** ** * ** ***
## Testing pitfalls
- **Resource leaks** : To prevent "Camera in Use" errors, in your `@After` block, call `cameraProvider.unbindAll`.
- **The "flaky initializer"**: Camera tests often fail on CI because the "Virtual Camera" takes too long to warm up. Use a sufficient explicit timeout for the first initialization.
- **Permission blockers** : To bypass the system permission dialogs, in your Espresso tests, use `GrantPermissionRule`.
- **Resolution mismatch** : Tests on emulators often default to 640x480. Ensure your `ResolutionSelector` handles this low-res fallback correctly.
+85
View File
@@ -0,0 +1,85 @@
Camera operations are among the most power-intensive tasks on mobile devices.
Without proactive management, the system throttle hardware, drop frames, or
force-close the camera app.
## The thermal management strategy
| Priority | Strategy | Implementation |
|---|---|---|
| **1. Inform** | Use case hints | Provide `StreamUseCase` to allow the OS to optimize hardware. |
| **2. Monitor** | Thermal state listener | Observe `PowerManager.addThermalStatusListener`. |
| **3. Act** | Graceful degradation | Dynamically reduce FPS, resolution, or disable demanding effects such as HDR. |
*** ** * ** ***
## Follow the implementation guide
### Stream use case optimization
Android 13 (API level 33) introduced `StreamUseCase`. This is the **single most
effective** way to tell the hardware how to balance quality versus power.
```kotlin
// In CameraX: Set the hint on your Use Case
val preview = Preview.Builder()
.setTargetName("Preview")
.apply {
Camera2Interop.Extender(this).setStreamUseCase(
CameraMetadata.SCALER_AVAILABLE_STREAM_USE_CASES_VIDEO_CALL.toLong()
)
}
.build()
```
<br />
Review the following key use cases for stream optimization:
- `PREVIEW`: This option is the default and provides a balanced configuration.
- `STILL_CAPTURE`: This option provides high-quality capture for short bursts.
- `VIDEO_RECORD`: This option maintains sustained power and is optimized for encoding.
- `VIDEO_CALL`: This option minimizes power consumption for long-duration sessions.
### Monitor thermal status
Don't wait for a crash. Monitor the `PowerManager` status and react before
`THERMAL_STATUS_CRITICAL`.
```kotlin
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
powerManager.addThermalStatusListener { status ->
when (status) {
PowerManager.THERMAL_STATUS_MODERATE -> {
// Signal to UI: "Device is warming up"
}
PowerManager.THERMAL_STATUS_SEVERE -> {
// ACTION: Reduce Frame Rate from 60fps to 30fps
// ACTION: Disable HDR or High-Quality Post-processing
}
PowerManager.THERMAL_STATUS_CRITICAL -> {
// ACTION: Close the camera session to prevent hardware damage
}
}
}
```
<br />
### Graceful degradation tiers
| Tier | Action | User impact |
|---|---|---|
| **Mild** | Stop background analysis using ML Kit | Minimal |
| **Moderate** | Cap frame rate to 30 FPS | Noticeable but smooth |
| **Severe** | Drop resolution from 1080p to 720p | Significant visual change |
| **Critical** | Shut down the session | App unusable in safe mode |
*** ** * ** ***
## Thermal pitfalls
- **The "double work" bug**: Don't run two high-resolution streams---such as a preview and a video capture stream---at different aspect ratios unless necessary. This forces the image signal processor (ISP) to perform double the scaling work, which generates excessive heat.
- **Surface overload** : Don't use multi-step `SurfaceProcessor` or `Media3Effect` chains during `THERMAL_STATUS_SEVERE`.
- **Flash heat** : Flash or torch usage generates high thermal load. Proactively disable the flash if thermal status is `SEVERE`.
+76
View File
@@ -0,0 +1,76 @@
Developing camera features for Wear OS is rarely about the watch's own lens, if
it even has one. It's almost always about creating a **Remote Viewfinder** to
control the phone's camera.
## The Wear OS remote mindset
| Feature | Phone camera app | Wear OS remote app |
|---|---|---|
| **Screen** | Rectangular (Large) | **Circular and less than 2 inches in size** |
| **Connectivity** | Local Hardware | **Bluetooth or Wi-Fi data layer API** |
| **Latency** | Direct and less than 20 ms | **Networked, 100 ms to 500 ms** |
| **Interaction** | Multi-touch gestures | **Rotary input or single taps** |
*** ** * ** ***
## Follow the implementation guide
### The circular UI challenge
Wear OS devices are often round. Standard rectangular layouts clip corner
buttons.
Follow these blueprint recommendations:
- Use `Horologist` or `Wear Compose` libraries.
- Use `ScalingLazyColumn` for lists so that items stay within the "safe zone" of the circular display.
- **Preview scaling**: Crop the center of the rectangular phone viewfinder to fit the circular watch screen.
### Stream the viewfinder
You can't send a raw 60 fps stream over Bluetooth. compress and throttle.
```kotlin
// Example: Sending a viewfinder frame to the watch
val bitmap = previewView.bitmap // Capture current frame
if (bitmap != null) {
val compressed = compressToJpeg(bitmap, quality = 50)
val request = PutDataMapRequest.create("/camera/preview").apply {
dataMap.putAsset("image", Asset.createFromBytes(compressed))
}
Wearable.getDataClient(context).putDataItem(request.asPutDataRequest())
}
```
<br />
**Optimization** : Cap the watch preview at **10-15 fps** to preserve battery and
bandwidth.
### Remote triggers and syncing
Use the `MessageClient` for low-latency commands like "Take Photo" or "Switch
Camera."
```kotlin
// Watch sends a trigger to the phone
Wearable.getMessageClient(context).sendMessage(nodeId, "/camera/capture", null)
```
<br />
### Rotary input support
On devices that support it, use the physical crown, Rotary Input, to control
**Zoom** or **Exposure**.
*** ** * ** ***
## Wear OS pitfalls
- **Corner clipping** : Placing a **Close** button in the top-right corner of a square layout makes it impossible to tap the button on a round watch display.
- **Battery drain**: Sustained Bluetooth data transfer, viewfinder sync, drains watch battery. Proactively close the remote app if the phone screen is turned off.
- **Node discovery** : The phone is possibly connected to multiple "Nodes" (watches, earbuds, or tablets). Ensure your `CapabilityClient` filters for the specific `camera_remote_host` capability.
- **Disconnect handling**: If the watch disconnects, the phone camera must stop its high-power preview to save energy.
+54
View File
@@ -0,0 +1,54 @@
Developing camera features for XR devices, headsets, and AR glasses requires a
shift from 2D pixel-pushing to 3D spatial awareness.
## Understand the XR development mindset
| Concept | Mobile focus | **XR focus** |
|---|---|---|
| **Input** | Raw camera stream | **Spatial tracking (visual-inertial odometry (VIO) or simultaneous localization and mapping (SLAM))** |
| **Output** | Screen viewfinder | **Stereo passthrough and occlusion** |
| **Constraint** | Battery life | **Motion-to-photon latency (less than 20 ms)** |
*** ** * ** ***
## Follow the implementation guide
### API selection
On XR devices, standard `CameraX` implementations are often restricted or
insufficient. Always use spatial software development kits (SDKs):
- **ARCore**: Use ARCore for plane detection, depth sensing, and motion tracking.
- **OpenXR**: Use OpenXR as the cross-platform standard for VR and AR rendering and input.
- **OEM SDKs**: Use manufacturer-specific libraries for hardware-accelerated passthrough.
### Handle spatial passthrough
Unlike a 2D viewport, XR passthrough is often system-managed.
**\[Key requirement\] Frame synchronization**: Synchronize your application's
frame clock with the headset's head-mounted display (HMD) pose.
```kotlin
// Example: Querying the spatial pose for the current camera frame
val headPose = xrSession.getHeadPose(frameTime)
val projectionMatrix = headPose.getProjectionMatrix(eyeIndex)
```
<br />
### Manage depth and occlusion
Digital content must respect real-world depth to ensure accurate occlusion.
- **Depth map** : Access raw depth data using `ARCore` or `SurfaceProcessor` to create an occlusion mask.
- **Hardware buffers** : Use `HardwareBuffer` to share camera frames directly with the GPU without CPU-side copies to minimize latency.
*** ** * ** ***
## XR pitfalls
- **The nausea limit**: Any processing that delays the viewfinder by more than 20 ms causes user sickness. Don't perform image processing on the main thread.
- **Privacy restrictions** : Some XR devices return a black frame if you attempt to record the "Passthrough" layer. Check `Session.isRecordingSupported`.
- **Field of view (FOV)**: The camera FOV possibly doesn't match the display FOV. Use the SDK's projection matrixes instead of calculating aspect ratios manually.
- **Front buffer rendering** : If the device supports it, use `FrontBufferRenderer` for real-time overlays to bypass standard double-buffering latency.
+12 -15
View File
@@ -8,7 +8,7 @@ description: Provides a complete workflow for implementing verified email retrie
license: Complete terms in LICENSE.txt
metadata:
author: Google LLC
last-updated: '2026-06-09'
last-updated: '2026-07-02'
keywords:
- implementation
- Android
@@ -214,14 +214,8 @@ The request contains the following key information:
- `hd` (hosted domain): In the response, this is empty.
> [!NOTE]
> **Note:** If `email_verified` is `true` and `hd` is empty in the response, it implies that the account is an authorized Google Account. Currently, Google does not issue [verifiable credentials](references/android/identity/digital-credentials/index.md) for Google Workspace Accounts. However, the `hd` field is present in verifiable credentials issued for non-workspace accounts. You are encouraged to implement handling this field to future-proof your app.
- If the email is non-@gmail.com, Google verified this email when the Google
Account was created, but there is no freshness claim. Therefore, for
non-Google emails, you should consider an additional challenge, such as an
OTP, to verify the user. To understand the schema of the credential and the
specific rules for validating fields like `email_verified`, refer to the
[Google Identity guides](https://developers.google.com/identity/gsi/web/guides/verify-google-id-token).
> **Note:** If `email_verified` is `true` and `hd` is empty in the response, it implies that the account is an authorized Google Account. Google does not issue [verifiable credentials](references/android/identity/digital-credentials/index.md) for Google Workspace Accounts. However, the `hd` field is present in verifiable credentials issued for non-workspace accounts. You are encouraged to implement handling this field to future-proof your app. If the email is non-@gmail.com, Google verified this email when the Google Account was created, but there is no freshness claim. Therefore, for non-Google emails, you should consider an additional challenge, such as an OTP, to verify the user. To understand the schema of the credential and the specific rules for validating fields like `email_verified`, refer to the [Google
> Identity guides](https://developers.google.com/identity/gsi/web/guides/verify-google-id-token).
- **nonce**: A unique, cryptographically secure random value is generated for
each request. This is critical for security, as it prevents replay attacks.
@@ -233,6 +227,9 @@ The request contains the following key information:
Next, wrap the `openId4vpRequest` JSON in a `GetDigitalCredentialOption`, create
a `GetCredentialRequest`, and call `getCredential()`.
> [!NOTE]
> **Note:** The `hd` and `email_verified` fields are hidden from users in Credential Manager's built-in UI. You cannot make a request with only these hidden fields- in case of such requests, the response is the [`GetCredentialCancellationException`](https://developer.android.com/reference/kotlin/androidx/credentials/exceptions/GetCredentialCancellationException).
## Present the request to the user
Present the user with the request, using the Credential Manager built-in UI.
@@ -335,8 +332,8 @@ additional metadata as well along with verified email:
}
*/
> [!NOTE]
> **Note:** We highly recommend that after receiving the verified email, you trigger Credential Manager's [passkey creation](https://developer.android.com/identity/credential-manager/passkeys/create-passkeys).
> [!IMPORTANT]
> **Important:** We highly recommend that after receiving the verified email, you trigger Credential Manager's [passkey creation](https://developer.android.com/identity/credential-manager/passkeys/create-passkeys).
## Server-side validation for account creation
@@ -400,10 +397,10 @@ standard passkey registration.
## WebView support
For the flow to work on a WebView, developers should implement a [JavaScript
bridge](references/android/identity/sign-in/credential-manager-webview.md) (JS Bridge) to facilitate the handoff. This bridge allows the
Webview to signal the native app, which can then perform the actual call
to the Credential Manager API.
For the flow to work on a [`WebView`](https://developer.android.com/reference/android/webkit/WebView), developers should implement a
[JavaScript bridge](references/android/identity/sign-in/credential-manager-webview.md) (JS Bridge) to facilitate the handoff. This bridge
allows the `WebView` object to signal the native app, which can then perform the
actual call to the Credential Manager API.
## See also
@@ -99,14 +99,8 @@ The request contains the following key information:
- `hd` (hosted domain): In the response, this is empty.
> [!NOTE]
> **Note:** If `email_verified` is `true` and `hd` is empty in the response, it implies that the account is an authorized Google Account. Currently, Google does not issue [verifiable credentials](https://developer.android.com/identity/digital-credentials#verifiable-credentials) for Google Workspace Accounts. However, the `hd` field is present in verifiable credentials issued for non-workspace accounts. You are encouraged to implement handling this field to future-proof your app.
- If the email is non-@gmail.com, Google verified this email when the Google
Account was created, but there is no freshness claim. Therefore, for
non-Google emails, you should consider an additional challenge, such as an
OTP, to verify the user. To understand the schema of the credential and the
specific rules for validating fields like `email_verified`, refer to the
[Google Identity guides](https://developers.google.com/identity/gsi/web/guides/verify-google-id-token).
> **Note:** If `email_verified` is `true` and `hd` is empty in the response, it implies that the account is an authorized Google Account. Google does not issue [verifiable credentials](https://developer.android.com/identity/digital-credentials#verifiable-credentials) for Google Workspace Accounts. However, the `hd` field is present in verifiable credentials issued for non-workspace accounts. You are encouraged to implement handling this field to future-proof your app. If the email is non-@gmail.com, Google verified this email when the Google Account was created, but there is no freshness claim. Therefore, for non-Google emails, you should consider an additional challenge, such as an OTP, to verify the user. To understand the schema of the credential and the specific rules for validating fields like `email_verified`, refer to the [Google
> Identity guides](https://developers.google.com/identity/gsi/web/guides/verify-google-id-token).
- **nonce**: A unique, cryptographically secure random value is generated for
each request. This is critical for security, as it prevents replay attacks.
@@ -118,6 +112,9 @@ The request contains the following key information:
Next, wrap the `openId4vpRequest` JSON in a `GetDigitalCredentialOption`, create
a `GetCredentialRequest`, and call `getCredential()`.
> [!NOTE]
> **Note:** The `hd` and `email_verified` fields are hidden from users in Credential Manager's built-in UI. You cannot make a request with only these hidden fields- in case of such requests, the response is the [`GetCredentialCancellationException`](https://developer.android.com/reference/kotlin/androidx/credentials/exceptions/GetCredentialCancellationException).
## Present the request to the user
Present the user with the request, using the Credential Manager built-in UI.
@@ -220,8 +217,8 @@ additional metadata as well along with verified email:
}
*/
> [!NOTE]
> **Note:** We highly recommend that after receiving the verified email, you trigger Credential Manager's [passkey creation](https://developer.android.com/identity/credential-manager/passkeys/create-passkeys).
> [!IMPORTANT]
> **Important:** We highly recommend that after receiving the verified email, you trigger Credential Manager's [passkey creation](https://developer.android.com/identity/credential-manager/passkeys/create-passkeys).
## Server-side validation for account creation
@@ -285,10 +282,10 @@ standard passkey registration.
## WebView support
For the flow to work on a WebView, developers should implement a [JavaScript
bridge](https://developer.android.com/identity/sign-in/credential-manager-webview) (JS Bridge) to facilitate the handoff. This bridge allows the
Webview to signal the native app, which can then perform the actual call
to the Credential Manager API.
For the flow to work on a [`WebView`](https://developer.android.com/reference/android/webkit/WebView), developers should implement a
[JavaScript bridge](https://developer.android.com/identity/sign-in/credential-manager-webview) (JS Bridge) to facilitate the handoff. This bridge
allows the `WebView` object to signal the native app, which can then perform the
actual call to the Credential Manager API.
## See also
+1 -1
View File
@@ -10,7 +10,7 @@ description: Instructions to make or update an app's UI so that it adapts to dif
license: Complete terms in LICENSE.txt
metadata:
author: Google LLC
last-updated: '2026-06-18'
last-updated: '2026-07-02'
keywords:
- android
- ui
@@ -6,7 +6,7 @@ This page describes how to implement basic `FlexBox` layouts.
`lib.versions.toml`.
[versions]
compose = "1.12.0-beta01"
compose = "1.12.0-beta02"
[libraries]
androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "compose" }
@@ -142,6 +142,83 @@ Grid(
![Row heights defined using the four primary track sizing options.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/grid/track-sizes.png) **Figure 3** . Row heights defined using the four primary track sizing options in `Grid`.
### Set the minimum size for flexible grid tracks
When a grid container has no remaining space,
a standard flexible track can shrink to `0.dp`.
To prevent this and ensure content isn't crushed,
use [`GridTrackSize.MinMax`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/GridTrackSize#MinMax(androidx.compose.ui.unit.Dp,androidx.compose.foundation.layout.Fr))
to enforce an explicit minimum size while keeping the track flexible.
The following example allocates at least `100.dp` to the first row:
```kotlin
Grid(
config = {
column(1f)
// The first row has a minimum height of 100.dp and can expand to
// the half of the remaining space.
row(GridTrackSize.MinMax(100.dp, 1.fr))
// The second row takes the half of the remaining space.
row(1.fr)
// The third row has a fixed height of 200.dp.
row(200.dp)
},
modifier = Modifier.size(360.dp) // Total grid height is 360.dp
) {
PastelRedCard("MinMax(100.dp, 1.fr)")
PastelGreenCard("Flex(1.fr)")
PastelBlueCard("Fixed(200.dp)")
}
```
<br />
![Row heights defined using the four primary track sizing options.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/grid/track-size-minmax.png) **Figure 4** . The first row has at least `100.dp` height.
### Set the minimum grid track size to place lazy lists
Standard flexible tracks automatically query the intrinsic sizes of
their children to establish a base size.
However, Jetpack Compose prohibits querying the intrinsic sizes of
[`SubcomposeLayout`](https://developer.android.com/reference/kotlin/androidx/compose/ui/layout/SubcomposeLayout.composable#SubcomposeLayout(androidx.compose.ui.Modifier,kotlin.Function2)), which backs components,
such as [`LazyColumn`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/lazy/LazyColumn.composable) and [`LazyRow`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/lazy/LazyRow.composable).
Placing a lazy list inside a standard flexible track causes
an [`IllegalStateException`](https://developer.android.com/reference/java/lang/IllegalStateException) crash.
To safely place lazy lists inside a flexible grid track,
use `MinMax` with an explicit minimum size (such as `0.dp`)
to bypass the intrinsic measurement pass.
```kotlin
Grid(
config = {
column(1f)
// The first row's height is determined by the height of the Text composable.
row(GridTrackSize.Auto)
// The second row occupies the remaining space, allowing the LazyColumn to scroll.
row(GridTrackSize.MinMax(0.dp, 1.fr))
gap(8.dp)
},
modifier = Modifier.size(width = 170.dp, height = 240.dp)
) {
Text("Lazy column in a Grid")
// The LazyColumn is placed in the second row, filling the remaining space.
LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp)) {
items(100) { number ->
PastelGreenCard("Card $number")
}
}
}
```
<br />
![Row heights defined using the four primary track sizing options.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/grid/lazy-column-in-grid.png) **Figure 5** . `LazyColumn` in a grid cell.
### Determine grid track size intrinsically
You can use [intrinsic sizing](https://developer.android.com/develop/ui/compose/layouts/intrinsic-measurements) for a `Grid`
@@ -175,7 +252,7 @@ Grid(
<br />
![Intrinsic sizes specified in the columns.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/grid/intrinsic-size.png) **Figure 4**. Intrinsic sizes specified in the columns.
![Intrinsic sizes specified in the columns.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/grid/intrinsic-size.png) **Figure 5**. Intrinsic sizes specified in the columns.
## Set gaps between rows and columns
@@ -211,7 +288,7 @@ Grid(
<br />
![Gaps between rows and columns.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/grid/gaps.png) **Figure 5**. Gaps between rows and columns.
![Gaps between rows and columns.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/grid/gaps.png) **Figure 6**. Gaps between rows and columns.
You can also use the convenience function [`gap`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/GridConfigurationScope#gap(androidx.compose.ui.unit.Dp))
to define gaps of the same column and row size,
@@ -6,7 +6,7 @@ This page describes how to implement basic [`Grid`](https://developer.android.co
`lib.versions.toml`.
[versions]
compose = "1.12.0-beta01"
compose = "1.12.0-beta02"
[libraries]
androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "compose" }
@@ -68,7 +68,7 @@ Inspector](https://developer.android.com/static/develop/ui/compose/images/li-sho
### Compose semantics
In Compose, [Semantics](https://developer.android.com/develop/ui/compose/semantics) describe your UI in an
In Compose, [Semantics](https://developer.android.com/develop/ui/compose/accessibility/semantics) describe your UI in an
alternative manner that is understandable for
[Accessibility](https://developer.android.com/develop/ui/compose/accessibility) services and for the
[Testing](https://developer.android.com/develop/ui/compose/testing) framework. You can use the Layout Inspector
@@ -9,7 +9,7 @@ description: Provides a structured workflow for migrating an Android XML View to
license: Complete terms in LICENSE.txt
metadata:
author: Google LLC
last-updated: '2026-06-20'
last-updated: '2026-07-02'
keywords:
- Jetpack Compose
- migration
@@ -56,7 +56,7 @@ following the logic in [references/identify-optimal-xml-candidate.md](references
Analyze the identified XML View's structure, hierarchy, and implementation
details.
Use [references/analysis-of-the-project-and-layout.md](https://developer.android.com/agents/skills/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/analysis-of-the-project-and-layout) to
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
@@ -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.
+9 -3
View File
@@ -8,7 +8,7 @@ description: Use this skill to integrate the Jetpack Compose Styles API into an
license: Complete terms in LICENSE.txt
metadata:
author: Google LLC
last-updated: '2026-06-11'
last-updated: '2026-07-02'
keywords:
- Jetpack Compose
- Styles
@@ -130,9 +130,15 @@ Refer to the official documentation to complete specific development tasks:
For each custom component (for example, `CustomButton`), complete the following
sequence:
1. If you are able to run an Android emulator, locate an existing screenshot test for the component. 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 Component. ELSE proceed to the next step without a screenshot test.
1. **Establish a visual baseline (If an emulator is available):**
- **If you CANNOT run an Android emulator:** Skip this step entirely and proceed to Step 2.
- **If you CAN run an Android emulator:** Perform the following to capture a baseline screenshot:
- **Option A:** Locate and run an existing screenshot test for the component.
- **Option B (If no test exists):** Create a test using the project's existing testing framework, then run it.
- **Option C (If no framework exists):** Create a minimal screenshot test using UI Automator or Espresso, then run it.
2. **Remove individual styling parameters** : Remove styling parameters such as `backgroundColor`, `shape`, `textStyle`, and `contentPadding` from the signature - anything that `StyleScope` supports.
3. **Add the style parameter** : Add `style: Style = Style` to the function signature.
3. **Add the style parameter** : Add `style: Style = Style` to the function signature. Always ensure the default value is exactly `Style` (e.g., `style:
Style = Style`) and not a specific style default like `ChipStyleDefault` or any other value.
4. **Declare state tracking** : If the component is interactable, create a `MutableStyleState` using the interaction source. Update state fields (such as `isEnabled`) inside the Composable to track the state correctly.
5. **Apply styleable modifier** : Replace specific layout modifiers on the root element with `Modifier.styleable()`.
6. **Move defaults to ComponentStyles** : Move hardcoded values from the component definition to a dedicated `Style` instance in `ComponentStyles.kt`.
+4 -4
View File
@@ -8,7 +8,7 @@ description: Learn how to install and migrate to Jetpack Navigation 3, and how t
license: Complete terms in LICENSE.txt
metadata:
author: Google LLC
last-updated: '2026-06-23'
last-updated: '2026-07-02'
keywords:
- recipe
- Android
@@ -55,7 +55,7 @@ Code examples showcasing common patterns.
- *[Basic](references/android/guide/navigation/navigation-3/recipes/basic.md)*: Shows most basic API usage.
- *[Saveable back stack](references/android/guide/navigation/navigation-3/recipes/basicsaveable.md)*: Shows basic API usage with a persistent back stack.
- *[Entry provider DSL](https://developer.android.com/guide/navigation/navigation-3/recipes/basicdsl)*: Shows basic API usage using the entryProvider DSL.
- *[Entry provider DSL](references/android/guide/navigation/navigation-3/recipes/basicdsl.md)*: Shows basic API usage using the entryProvider DSL.
### Common UI
@@ -63,7 +63,7 @@ Code examples showcasing common patterns.
### Deep links
- *[Basic](https://developer.android.com/guide/navigation/navigation-3/recipes/deeplinks-basic)*: Shows how to parse a deep link URL from an Android Intent into a navigation key.
- *[Basic](references/android/guide/navigation/navigation-3/recipes/deeplinks-basic.md)*: Shows how to parse a deep link URL from an Android Intent into a navigation key.
- *[Advanced](references/android/guide/navigation/navigation-3/recipes/deeplinks-advanced.md)*: Shows how to handle deep links with a synthetic back stack and correct "Up" navigation behavior.
### Scenes
@@ -98,7 +98,7 @@ Code examples showcasing common patterns.
### Architecture
- *[Modularized navigation code (Hilt)](references/android/guide/navigation/navigation-3/recipes/modular-hilt.md)*: Demonstrates how to decouple navigation code into separate modules using Hilt or Dagger for DI.
- *[Modularized navigation code (Koin)](https://developer.android.com/guide/navigation/navigation-3/recipes/modular-koin)*: Demonstrates how to decouple navigation code into separate modules using Koin for DI.
- *[Modularized navigation code (Koin)](references/android/guide/navigation/navigation-3/recipes/modular-koin.md)*: Demonstrates how to decouple navigation code into separate modules using Koin for DI.
### Working with ViewModel
@@ -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<RouteType>` 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<RouteA> {
ContentGreen("Welcome to Nav3") {
Button(onClick = dropUnlessResumed {
backStack.add(RouteB("123"))
}) {
Text("Click to navigate")
}
}
}
entry<RouteB> { key ->
ContentBlue("Route id: ${key.id} ")
}
}
)
}
}
}
```
@@ -0,0 +1,744 @@
# 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.common.deeplink.EntryScreen
import com.example.nav3recipes.common.deeplink.FriendsList
import com.example.nav3recipes.common.deeplink.LIST_USERS
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.deeplink.basic.util.DeepLinkMatchResult
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.KeyDecoder
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
/**
* 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<DeepLinkPattern<out NavKey>> = 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?) {
setEdgeToEdgeConfig()
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<NavKey> = rememberNavBackStack(key)
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider {
entry<HomeKey> { key ->
EntryScreen(key.name) {
TextContent("<matches exact url>")
}
}
entry<UsersKey> { key ->
EntryScreen("${key.name} : ${key.filter}") {
TextContent("<matches path argument>")
val list = when {
key.filter.isEmpty() -> LIST_USERS
key.filter == UsersKey.FILTER_OPTION_ALL -> LIST_USERS
else -> LIST_USERS.take(5)
}
FriendsList(list)
}
}
entry<SearchKey> { search ->
EntryScreen(search.name) {
TextContent("<matches query parameters, if any>")
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.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.common.deeplink.PaddedButton
import com.example.nav3recipes.common.deeplink.TextContent
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.ui.setEdgeToEdgeConfig
/**
* 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?) {
setEdgeToEdgeConfig()
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<String, String>() }
// 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<String, Any>,
) : 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<String> = 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<T : NavKey>(
val serializer: KSerializer<T>,
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<PathSegment> = 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<String, TypeParser> = 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<T : NavKey>(
val request: DeepLinkRequest,
val deepLinkPattern: DeepLinkPattern<T>
) {
/**
* Match a [DeepLinkRequest] to a [DeepLinkPattern].
*
* Returns a [DeepLinkMatchResult] if this matches the pattern, returns null otherwise
*/
fun match(): DeepLinkMatchResult<T>? {
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<String, Any>()
// 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<T : NavKey>(
val serializer: KSerializer<T>,
val args: Map<String, Any>
)
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}}"
```
@@ -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<Profile> { 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<ConversationList> {
ConversationListScreen(
onConversationClicked = { conversationDetail ->
get<Navigator>().goTo(conversationDetail)
}
)
}
navigation<ConversationDetail> { key ->
ConversationDetailScreen(key) {
get<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.koin
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.SnapshotStateList
class Navigator(startDestination: Any) {
val backStack : SnapshotStateList<Any> = 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()
)
}
}
}
}
```
+2 -2
View File
@@ -6,7 +6,7 @@ description: Helps developers integrate, debug, and resolve Play Engage SDK impl
license: Complete terms in LICENSE.txt
metadata:
author: Google LLC
last-updated: '2026-06-19'
last-updated: '2026-07-02'
keywords:
- android
- engage
@@ -32,7 +32,7 @@ Follow these steps to assist the developer:
- Always refer to [common.md](references/common.md) for common entities.
- Ask which cluster type they want to publish from the supported cluster types for that vertical.
- Find the method to call from `{VERTICAL}.md` in the **[references/schemas/](references/schemas)** directory for the specified cluster. Each method will specify the request it expects.
- Get the request structure from [requests.md](https://developer.android.com/agents/skills/play/engage-sdk-integration/references/requests) and clusters from [clusters.md](references/clusters.md). Then suggest and use sources to fill the fields in the request structure correctly, along with the required entities and clusters.
- Get the request structure from [requests.md](references/requests.md) and clusters from [clusters.md](references/clusters.md). Then suggest and use sources to fill the fields in the request structure correctly, along with the required entities and clusters.
2. **Generate Structured Boilerplate Code:**
- Create a new directory for all Engage-related code. Name the directory to match the naming convention of the existing codebase.
@@ -1,5 +1,8 @@
<br />
[Engage SDK](https://developer.android.com/guide/playcore/engage) lets you deliver personalized recommendations
and continuation content directly to users on Google TV.
This guide covers how to get started with Engage SDK integrations for TV. After
you complete the pre-work on this page, you can integrate one or more
of the TV features:
@@ -12,7 +15,7 @@ of the TV features:
Before you begin, complete the following steps:
1. [Express interest in developing with Engage](http://g.co/tv/vda) to enroll in
1. [Express interest in developing with Engage SDK](http://g.co/tv/engage) to enroll in
the program, if eligible.
2. Verify that your app targets Android 4.4 (API level 19) or higher for this
@@ -137,7 +137,7 @@ class EngageWorker(context: Context, workerParams: WorkerParameters) : Coroutine
return when (publishType) {
Constants.PUBLISH_TYPE_RECOMMENDATIONS -> publishRecommendations()
// Constants.PUBLISH_TYPE_FEATURED -> publishFeatured()
// Constants.PUBLISH_TYPE_CONTINUATION-> publishContinuation()
Constants.PUBLISH_TYPE_CONTINUATION -> publishContinuation()
Constants.PUBLISH_TYPE_USER_ACCOUNT_MANAGEMENT -> publishUserAccountManagement()
else -> Result.failure()
}
@@ -152,6 +152,21 @@ class EngageWorker(context: Context, workerParams: WorkerParameters) : Coroutine
return publishAndProvideResult(publishTask)
}
private suspend fun publishContinuation(): Result {
// Empty Continuation Guard: If there is no continuation content,
// we must delete the cluster instead of publishing an empty one on the UI.
if (getContinuationData().isEmpty()) {
val deleteTask = client.deleteContinuationCluster()
return publishAndProvideResult(deleteTask)
}
val publishTask: Task<Void> =
client.publishContinuationCluster(
clusterRequestFactory.constructContinuationClusterRequest()
)
return publishAndProvideResult(publishTask)
}
private suspend fun publishUserAccountManagement(): Result {
val publishTask: Task<Void>
if (isAccountSignedIn()) {
@@ -181,6 +196,11 @@ class EngageWorker(context: Context, workerParams: WorkerParameters) : Coroutine
// ...
}
private fun getContinuationData(): List<Any> {
// Implement your app's data loading logic here.
// ...
}
private suspend fun publishAndProvideResult(
publishTask: Task<Void>
): Result {
@@ -291,11 +311,28 @@ class ClusterRequestFactory(context: Context) {
val items = appDataRepository.getRecommendations()
val recommendationCluster = com.google.android.engage.common.datamodel.RecommendationCluster.Builder()
.setTitle("Recommended Content") // Required field
.setRecommendationClusterType(com.google.android.engage.common.datamodel.RecommendationClusterType.TYPE_TOP_PICKS_FOR_YOU) // Required field
for (item in items) {
recommendationCluster.addEntity(ItemToEntityConverter.convert(item))
}
return com.google.android.engage.service.PublishRecommendationClustersRequest.Builder()
.addRecommendationCluster(recommendationCluster.build())
.setAccountProfile(accountProfile) // Set the account profile on the request for personalization/sync
.build()
}
fun constructContinuationClusterRequest(): com.google.android.engage.service.PublishContinuationClusterRequest {
val items = appDataRepository.getContinuationData()
val continuationCluster = com.google.android.engage.common.datamodel.ContinuationCluster.Builder()
.setAccountProfile(accountProfile) // Set the account profile on the request for personalization/sync
for (item in items) {
continuationCluster.addEntity(ItemToEntityConverter.convert(item))
}
return com.google.android.engage.service.PublishContinuationClusterRequest.Builder()
.setContinuationCluster(continuationCluster.build())
.build()
}
@@ -344,7 +381,7 @@ object Constants {
const val PUBLISH_TYPE_KEY = "PUBLISH_TYPE"
const val PUBLISH_TYPE_RECOMMENDATIONS = "RECOMMENDATIONS"
const val PUBLISH_TYPE_FEATURED = "FEATURED"
// const val PUBLISH_TYPE_CONTINUATION = "CONTINUATION"
const val PUBLISH_TYPE_CONTINUATION = "CONTINUATION"
// ...
const val PUBLISH_TYPE_USER_ACCOUNT_MANAGEMENT = "USER_ACCOUNT_MANAGEMENT"
// const val PUBLISH_TYPE_FOOD_SHOPPING_CARD = "FOOD_SHOPPING_CARD"
@@ -0,0 +1,231 @@
This file defines the request structures for publishing various data models in
the Engage SDK.
{
"PublishRecommendationClustersRequest": {
"package": "com.google.android.engage.service.PublishRecommendationClustersRequest",
"fields": {
"recommendationClusters": {
"type": "List<RecommendationCluster>",
"requirement": "Required",
"adder": "addRecommendationCluster(RecommendationCluster)",
"getter": "getRecommendationClusters()"
},
"accountProfile": {
"type": "@NonNull AccountProfile",
"requirement": "Optional",
"setter": "setAccountProfile(@NonNull AccountProfile)",
"getter": "getAccountProfile()"
},
"syncAcrossDevices": {
"type": "Boolean",
"requirement": "Optional",
"setter": "setSyncAcrossDevices(boolean)",
"getter": "getSyncAcrossDevices()"
}
}
},
"PublishFeaturedClusterRequest": {
"package": "com.google.android.engage.service.PublishFeaturedClusterRequest",
"fields": {
"featuredCluster": {
"type": "FeaturedCluster",
"requirement": "Required",
"setter": "setFeaturedCluster(FeaturedCluster)",
"getter": "getFeaturedCluster()"
}
}
},
"DeleteClustersRequest": {
"package": "com.google.android.engage.service.DeleteClustersRequest",
"fields": {
"clusterTypes": {
"type": "List<@ClusterType int>",
"requirement": "Optional",
"adder": "addClusterType(@ClusterType int)"
},
"deleteReason": {
"type": "@DeleteReason int",
"requirement": "Optional",
"setter": "setDeleteReason(@DeleteReason int)",
"getter": "getDeleteReason()"
},
"accountProfile": {
"requirement": "Optional",
"setter": "setAccountProfile(AccountProfile)",
"type": "AccountProfile",
"getter": "getAccountProfile()"
},
"syncAcrossDevices": {
"requirement": "Optional",
"setter": "setSyncAcrossDevices(boolean)",
"type": "Boolean",
"getter": "getSyncAcrossDevices()"
}
}
},
"PublishContinuationClusterRequest": {
"package": "com.google.android.engage.service.PublishContinuationClusterRequest",
"fields": {
"continuationCluster": {
"type": "ContinuationCluster",
"requirement": "Required",
"setter": "setContinuationCluster(ContinuationCluster)",
"getter": "getContinuationCluster()"
}
}
},
"PublishStatusRequest": {
"package": "com.google.android.engage.service.PublishStatusRequest",
"fields": {
"statusCode": {
"type": "@AppEngagePublishStatusCode int",
"requirement": "Required",
"setter": "setStatusCode(@AppEngagePublishStatusCode int)",
"getter": "getStatusCode()"
}
}
},
"PublishSubscriptionRequest": {
"package": "com.google.android.engage.service.PublishSubscriptionRequest",
"fields": {
"subscriptionClusters": {
"type": "List<SubscriptionCluster>",
"requirement": "Required"
},
"accountProfile": {
"type": "AccountProfile",
"requirement": "Required",
"setter": "setAccountProfile(AccountProfile)",
"getter": "getAccountProfile()"
},
"subscription": {
"requirement": "Required",
"setter": "setSubscription(SubscriptionEntity)",
"type": "SubscriptionEntity",
"getter": "getSubscription()"
}
}
},
"PublishUserAccountManagementRequest": {
"package": "com.google.android.engage.service.PublishUserAccountManagementRequest",
"fields": {
"actionUri": {
"type": "Uri",
"requirement": "Required"
},
"signInCardEntity": {
"type": "SignInCardEntity",
"requirement": "Required",
"setter": "setSignInCardEntity(SignInCardEntity)"
},
"userSettingsCardEntity": {
"requirement": "Required",
"setter": "setUserSettingsCardEntity(UserSettingsCardEntity)",
"type": "UserSettingsCardEntity"
}
}
},
"PublishShoppingCartClusterRequest": {
"package": "com.google.android.engage.shopping.service.PublishShoppingCartClusterRequest",
"fields": {
"shoppingCart": {
"requirement": "Required",
"setter": "setShoppingCart(ShoppingCart)",
"type": "ShoppingCart",
"getter": "getShoppingCart()"
}
}
},
"PublishShoppingListsRequest": {
"package": "com.google.android.engage.shopping.service.PublishShoppingListsRequest",
"fields": {
"shoppingLists": {
"type": "List<ShoppingList>",
"requirement": "Optional",
"adder": "addShoppingList(ShoppingList)",
"getter": "getShoppingLists()",
"adderAll": "addShoppingLists(List<ShoppingList>)"
}
}
},
"PublishShoppingOrderTrackingClusterRequest": {
"package": "com.google.android.engage.shopping.service.PublishShoppingOrderTrackingClusterRequest",
"fields": {
"shoppingOrderTrackingCluster": {
"type": "ShoppingOrderTrackingCluster",
"requirement": "Required",
"setter": "setShoppingOrderTrackingCluster(ShoppingOrderTrackingCluster)",
"getter": "getShoppingOrderTrackingCluster()"
}
}
},
"PublishShoppingReorderClusterRequest": {
"package": "com.google.android.engage.shopping.service.PublishShoppingReorderClusterRequest",
"fields": {
"reorderCluster": {
"type": "ShoppingReorderCluster",
"requirement": "Required",
"setter": "setReorderCluster(ShoppingReorderCluster)",
"getter": "getReorderCluster()"
}
}
},
"PublishFoodShoppingCartsRequest": {
"package": "com.google.android.engage.food.service.PublishFoodShoppingCartsRequest",
"fields": {
"foodShoppingCarts": {
"type": "List<FoodShoppingCart>",
"requirement": "Optional",
"adder": "addFoodShoppingCart(FoodShoppingCart)",
"getter": "getFoodShoppingCarts()",
"adderAll": "addFoodShoppingCarts(List<FoodShoppingCart>)"
}
}
},
"PublishFoodShoppingListsRequest": {
"package": "com.google.android.engage.food.service.PublishFoodShoppingListsRequest",
"fields": {
"foodShoppingLists": {
"type": "List<FoodShoppingList>",
"requirement": "Optional",
"adder": "addFoodShoppingList(FoodShoppingList)",
"getter": "getFoodShoppingLists()",
"adderAll": "addFoodShoppingLists(List<FoodShoppingList>)"
}
}
},
"PublishReorderClusterRequest": {
"package": "com.google.android.engage.food.service.PublishReorderClusterRequest",
"fields": {
"reorderCluster": {
"requirement": "Required",
"setter": "setReorderCluster(FoodReorderCluster)",
"type": "FoodReorderCluster",
"getter": "getReorderCluster()"
}
}
},
"PublishContinueSearchClusterRequest": {
"package": "com.google.android.engage.travel.service.PublishContinueSearchClusterRequest",
"fields": {
"continueSearchCluster": {
"type": "ContinueSearchCluster",
"requirement": "Required",
"setter": "setContinueSearchCluster(ContinueSearchCluster)",
"getter": "getContinueSearchCluster()"
}
}
},
"PublishReservationClusterRequest": {
"package": "com.google.android.engage.travel.service.PublishReservationClusterRequest",
"fields": {
"reservationCluster": {
"type": "ReservationCluster",
"requirement": "Required",
"setter": "setReservationCluster(ReservationCluster)",
"getter": "getReservationCluster()"
}
}
}
}
@@ -6,7 +6,7 @@ description: Use this skill when upgrading or migrating an Android project from
license: Complete terms in LICENSE.txt
metadata:
author: Google LLC
last-updated: '2026-06-19'
last-updated: '2026-07-02'
keywords:
- android
- play billing
+1 -8
View File
@@ -7,7 +7,7 @@ description: Best practices for Android Intent security. Use this skill when aud
license: Complete terms in LICENSE.txt
metadata:
author: Google LLC
last-updated: '2026-06-24'
last-updated: '2026-06-25'
keywords:
- recipe
- Android
@@ -24,13 +24,6 @@ metadata:
- Best Practices
---
auditing component configurations in AndroidManifest.xml (activities, services,
receivers) or source code handling incoming Intents (getIntent,
getParcelableExtra) to prevent Intent Redirection and unauthorized access.
keywords_public: recipe, Android, Security, Intent, Redirection, PendingIntent,
ContentProvider, Service ,Signature, Verification, Sanitizer, Vulnerability,
Best Practices
This skill provides guidelines and patterns to secure Android components
(Activities, Services, Broadcast Receivers, Content Providers) and handle
Intents safely, preventing privilege escalation and unauthorized access.
+1 -1
View File
@@ -6,7 +6,7 @@ description: Analyze and create a testing strategy for native Android apps - ins
license: Complete terms in LICENSE.txt
metadata:
author: Google LLC
last-updated: '2026-06-02'
last-updated: '2026-06-25'
keywords:
- android
- testing
@@ -21,7 +21,7 @@ can't call `setContent` on a rule created with `createAndroidComposeRule()` if
the activity already calls it.
A common pattern to achieve this is to create an `AndroidComposeTestRule` using
an empty activity such as [`ComponentActivity`](https://developer.android.com/reference/androidx/activity/ComponentActivity)).
an empty activity such as [`ComponentActivity`](https://developer.android.com/reference/androidx/activity/ComponentActivity).
class MyComposeTest {
+1 -1
View File
@@ -8,7 +8,7 @@ description: Expert guidance for working with Wear OS Compose Material3. Use thi
license: Complete terms in LICENSE.txt
metadata:
author: Google LLC
last-updated: '2026-06-18'
last-updated: '2026-07-02'
keywords:
- Wear OS
- Compose
@@ -27,19 +27,19 @@ M3 has a separate package and version to M2.5:
### M3
implementation("androidx.wear.compose:compose-material3:1.7.0-alpha04")
implementation("androidx.wear.compose:compose-material3:1.7.0-alpha05")
See the latest M3 versions on the [Wear Compose Material 3 releases page](https://developer.android.com/jetpack/androidx/releases/wear-compose-m3).
Wear Compose Foundation library version 1.7.0-alpha04 introduced
Wear Compose Foundation library version 1.7.0-alpha05 introduced
some new components that are designed to work with Material 3 components.
Similarly, `SwipeDismissableNavHost` from Wear Compose Navigation library has an
updated animation when running on Wear OS 6 (API level 36) or higher. When
updating to Wear Compose Material 3 version, we suggest to also update the Wear
Compose Foundation and Navigation libraries:
implementation("androidx.wear.compose:compose-foundation:1.7.0-alpha04")
implementation("androidx.wear.compose:compose-navigation:1.7.0-alpha04")
implementation("androidx.wear.compose:compose-foundation:1.7.0-alpha05")
implementation("androidx.wear.compose:compose-navigation:1.7.0-alpha05")
## Theme
@@ -329,7 +329,7 @@ Here is a full list of all the Material 3 components:
And finally a list of some relevant components from Wear Compose Foundation
library:
| Wear Compose Foundation 1.7.0-alpha04 | |
| Wear Compose Foundation 1.7.0-alpha05 | |
|---|---|
| [androidx.wear.compose.foundation.hierarchicalFocusGroup](https://developer.android.com/reference/kotlin/androidx/wear/compose/foundation/package-summary#(androidx.compose.ui.Modifier).hierarchicalFocusGroup(kotlin.Boolean)) | Used to annotate composables in an application, to keep track of the active part of the composition and coordinate focus. |
| [androidx.wear.compose.foundation.pager.HorizontalPager](https://developer.android.com/reference/kotlin/androidx/wear/compose/foundation/pager/package-summary#HorizontalPager(androidx.wear.compose.foundation.pager.PagerState,androidx.compose.ui.Modifier,androidx.compose.foundation.layout.PaddingValues,kotlin.Int,androidx.compose.foundation.gestures.TargetedFlingBehavior,kotlin.Boolean,androidx.wear.compose.foundation.GestureInclusion,kotlin.Boolean,kotlin.Function1,androidx.wear.compose.foundation.rotary.RotaryScrollableBehavior,kotlin.Function2)) | A horizontally scrolling pager, built on the Compose Foundation components with Wear-specific enhancements to improve performance and adherence to Wear OS guidelines. |
@@ -9,7 +9,7 @@ description: Provides guidelines for developing projected Android XR apps for di
license: Complete terms in LICENSE.txt
metadata:
author: Google LLC
last-updated: '2026-06-23'
last-updated: '2026-07-02'
keywords:
- Jetpack Compose Glimmer
- audio glasses
@@ -207,7 +207,7 @@ If you are creating a Glimmer Button component, read the:
- **Developer Guidance:** [Jetpack Compose Glimmer: Buttons](references/android/develop/xr/jetpack-xr-sdk/jetpack-compose-glimmer/buttons.md)
- **API Source Code:** Use [references/button-source.md](references/button-source.md).
- **Implementation Samples:** Use [references/button-samples-source.md](https://developer.android.com/agents/skills/xr/display-glasses-with-jetpack-compose-glimmer/references/button-samples-source).
- **Implementation Samples:** Use [references/button-samples-source.md](references/button-samples-source.md).
#### Title Chips
@@ -277,7 +277,7 @@ If you are creating a Glimmer List component, read the:
- **API Source Code (List):** Use [references/list-source.md](references/list-source.md).
- **API Source Code (ListItem):** Use [references/listitem-source.md](references/listitem-source.md).
- **API Source Code (ListState):** Use [references/liststate-source.md](https://developer.android.com/agents/skills/xr/display-glasses-with-jetpack-compose-glimmer/references/liststate-source).
- **API Source Code (ListState):** Use [references/liststate-source.md](references/liststate-source.md).
#### Stacks
@@ -0,0 +1,176 @@
When creating a Glimmer Button component, refer to the following implementation
samples in `ButtonSamples.kt`:
```kotlin
/*
* 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 androidx.xr.glimmer.samples
import androidx.annotation.Sampled
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.xr.glimmer.Button
import androidx.xr.glimmer.ButtonSize
import androidx.xr.glimmer.GlimmerTheme
import androidx.xr.glimmer.Icon
import androidx.xr.glimmer.Text
import androidx.xr.glimmer.list.GlimmerLazyColumn
@Composable
fun ButtonSampleUsage() {
GlimmerLazyColumn(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxSize().wrapContentSize(Alignment.Center),
) {
item { ButtonSample() }
item { ButtonWithLeadingIconSample() }
item { ButtonWithTrailingIconSample() }
item { ButtonWithLeadingAndTrailingIconSample() }
item { LargeButtonSample() }
item { LargeButtonWithLeadingIconSample() }
item { LargeButtonWithTrailingIconSample() }
item { LargeButtonWithLeadingAndTrailingIconSample() }
}
}
@Sampled
@Composable
fun ButtonSample() {
Button(onClick = {}) { Text("Send") }
}
@Sampled
@Composable
fun ButtonWithLeadingIconSample() {
Button(onClick = {}, leadingIcon = { Icon(FavoriteIcon, "Localized description") }) {
Text("Send")
}
}
@Composable
private fun ButtonWithTrailingIconSample() {
Button(onClick = {}, trailingIcon = { Icon(FavoriteIcon, "Localized description") }) {
Text("Send")
}
}
@Composable
private fun ButtonWithLeadingAndTrailingIconSample() {
Button(
onClick = {},
leadingIcon = { Icon(FavoriteIcon, "Localized description") },
trailingIcon = { Icon(FavoriteIcon, "Localized description") },
) {
Text("Send")
}
}
@Sampled
@Composable
fun LargeButtonSample() {
Button(onClick = {}, buttonSize = ButtonSize.Large) { Text("Send") }
}
@Composable
private fun LargeButtonWithLeadingIconSample() {
Button(
onClick = {},
buttonSize = ButtonSize.Large,
leadingIcon = { Icon(FavoriteIcon, "Localized description") },
) {
Text("Send")
}
}
@Composable
private fun LargeButtonWithTrailingIconSample() {
Button(
onClick = {},
buttonSize = ButtonSize.Large,
trailingIcon = { Icon(FavoriteIcon, "Localized description") },
) {
Text("Send")
}
}
@Composable
private fun LargeButtonWithLeadingAndTrailingIconSample() {
Button(
onClick = {},
buttonSize = ButtonSize.Large,
leadingIcon = { Icon(FavoriteIcon, "Localized description") },
trailingIcon = { Icon(FavoriteIcon, "Localized description") },
) {
Text("Send")
}
}
@Preview
@Composable
private fun ButtonPreview() {
GlimmerTheme { ButtonSample() }
}
@Preview
@Composable
private fun ButtonWithLeadingIconPreview() {
GlimmerTheme { ButtonWithLeadingIconSample() }
}
@Preview
@Composable
private fun ButtonWithTrailingIconPreview() {
GlimmerTheme { ButtonWithTrailingIconSample() }
}
@Preview
@Composable
private fun ButtonWithLeadingAndTrailingIconPreview() {
GlimmerTheme { ButtonWithLeadingAndTrailingIconSample() }
}
@Preview
@Composable
private fun LargeButtonPreview() {
GlimmerTheme { LargeButtonSample() }
}
@Preview
@Composable
private fun LargeButtonWithLeadingIconPreview() {
GlimmerTheme { LargeButtonWithLeadingIconSample() }
}
@Preview
@Composable
private fun LargeButtonWithTrailingIconPreview() {
GlimmerTheme { LargeButtonWithTrailingIconSample() }
}
@Preview
@Composable
private fun LargeButtonWithLeadingAndTrailingIconPreview() {
GlimmerTheme { LargeButtonWithLeadingAndTrailingIconSample() }
}
```
<br />
@@ -33,6 +33,7 @@ import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.xr.glimmer.ActionCard
import androidx.xr.glimmer.Button
import androidx.xr.glimmer.Card
import androidx.xr.glimmer.GlimmerTheme
@@ -47,9 +48,9 @@ fun CardSampleUsage() {
item { CardWithTrailingIconSample() }
item { CardWithTitleAndSubtitleAndLeadingIconSample() }
item { CardWithTitleAndHeaderSample() }
item { CardWithTitleAndActionSample() }
item { ActionCardWithTitleSample() }
item { CardWithTitleAndLeadingIconAndHeader() }
item { CardWithTitleAndLeadingIconAndHeaderAndAction() }
item { ActionCardWithTitleAndLeadingIconAndHeader() }
item { CardWithLongText() }
item { CardWithTitleAndSubtitleAndLeadingIconLongText() }
item { CardWithTitleAndSubtitleAndLeadingIconAndTrailingIconLongText() }
@@ -97,8 +98,8 @@ fun CardWithTitleAndHeaderSample() {
@Sampled
@Composable
fun CardWithTitleAndActionSample() {
Card(action = { Button(onClick = {}) { Text("Send") } }, title = { Text("Title") }) {
fun ActionCardWithTitleSample() {
ActionCard(action = { Button(onClick = {}) { Text("Send") } }, title = { Text("Title") }) {
Text("This is a card with a title and action")
}
}
@@ -158,8 +159,8 @@ fun CardWithTitleAndLeadingIconAndHeader() {
}
@Composable
fun CardWithTitleAndLeadingIconAndHeaderAndAction() {
Card(
fun ActionCardWithTitleAndLeadingIconAndHeader() {
ActionCard(
action = {
Button(onClick = {}, trailingIcon = { Icon(FavoriteIcon, "Localized description") }) {
Text("Send")
@@ -239,8 +240,8 @@ private fun CardWithTitleAndHeaderPreview() {
@Preview
@Composable
private fun CardWithTitleAndActionPreview() {
GlimmerTheme { CardWithTitleAndActionSample() }
private fun ActionCardWithTitlePreview() {
GlimmerTheme { ActionCardWithTitleSample() }
}
@Preview
@@ -251,8 +252,8 @@ private fun CardWithTitleAndLeadingIconAndHeaderPreview() {
@Preview
@Composable
private fun CardWithTitleAndLeadingIconAndHeaderAndActionPreview() {
GlimmerTheme { CardWithTitleAndLeadingIconAndHeaderAndAction() }
private fun ActionCardWithTitleAndLeadingIconAndHeaderPreview() {
GlimmerTheme { ActionCardWithTitleAndLeadingIconAndHeader() }
}
@Preview
@@ -242,21 +242,21 @@ public fun Card(
}
/**
* Card is a component used to group related information into a single digestible unit. A card can
* adapt to display a wide range of content, from simple text blurbs to more complex summaries with
* multiple elements. A card contains text [content], and may also have any combination of [title],
* [subtitle], [leadingIcon], and [trailingIcon]. If specified, [title] is placed on top of the
* [subtitle], which is placed on top of the [content]. A card fills the maximum width available by
* default.
* ActionCard is a version of a card that contains a primary [action] that is placed in the center
* of the bottom edge of the card. The action should be a [Button], and represents the action that
* will be performed when this card is interacted with. The main card itself is not focusable - the
* [action] takes the focus instead.
*
* This Card contains an [action] that is placed on the center of the bottom edge of the card. The
* action should be a [Button], and represents the action that will be performed when this card is
* interacted with. The main card itself is not focusable - the [action] takes the focus instead.
* ActionCard is a component used to group related information into a single digestible unit. An
* action card can adapt to display a wide range of content, from simple text blurbs to more complex
* summaries with multiple elements. An action card contains text [content], and may also have any
* combination of [title], [subtitle], [leadingIcon], and [trailingIcon]. If specified, [title] is
* placed on top of the [subtitle], which is placed on top of the [content]. An action card fills
* the maximum width available by default.
*
* For more documentation and samples of the other card parameters, see the other card overload
* without an action.
* For more documentation and samples of the other cards, see [Card].
*
* @sample androidx.xr.glimmer.samples.CardWithTitleAndActionSample
* @sample androidx.xr.glimmer.samples.ActionCardWithTitleSample
* @param action the action for this card. This should be a [Button], and represents the action
* performed when a user interacts with this card. The action is placed overlapping the bottom
* edge of the card.
@@ -285,7 +285,7 @@ public fun Card(
* be limited to 10 lines of text.
*/
@Composable
public fun Card(
public fun ActionCard(
action: @Composable () -> Unit,
modifier: Modifier = Modifier,
title: @Composable (() -> Unit)? = null,
@@ -0,0 +1,420 @@
When creating a Glimmer List component, refer to the following source code in
`ListState.kt` for creating a state for the list:
```kotlin
/*
* 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 androidx.xr.glimmer.list
import androidx.annotation.IntRange
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.ScrollIndicatorState
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.ScrollScope
import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.lazy.layout.LazyLayoutPinnedItemList
import androidx.compose.runtime.Composable
import androidx.compose.runtime.annotation.FrequentlyChangingValue
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.neverEqualPolicy
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.layout.AlignmentLine
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.Remeasurement
import androidx.compose.ui.layout.RemeasurementModifier
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.xr.glimmer.list.ListState.Companion.Saver
import kotlin.math.abs
/**
* Creates a [ListState] that is remembered across compositions.
*
* Changes to the provided initial values will **not** result in the state being recreated or
* changed in any way if it has already been created.
*
* @param initialFirstVisibleItemIndex the initial value for [ListState.firstVisibleItemIndex]
* @param initialFirstVisibleItemScrollOffset the initial value for
* [ListState.firstVisibleItemScrollOffset]
*/
@Composable
public fun rememberListState(
initialFirstVisibleItemIndex: Int = 0,
initialFirstVisibleItemScrollOffset: Int = 0,
): ListState =
rememberSaveable(saver = ListState.Saver) {
ListState(initialFirstVisibleItemIndex, initialFirstVisibleItemScrollOffset)
}
/**
* A state object that can be hoisted to control and observe scrolling.
*
* In most cases, this will be created via [rememberListState].
*
* @param firstVisibleItemIndex the initial value for [ListState.firstVisibleItemIndex]
* @param firstVisibleItemScrollOffset the initial value for
* [ListState.firstVisibleItemScrollOffset]
*/
public class ListState(firstVisibleItemIndex: Int = 0, firstVisibleItemScrollOffset: Int = 0) :
ScrollableState {
private val backingState = ScrollableState { -onScroll(-it) }
// TODO: b/414961654 - Consider making this abstraction around "anchor item".
/** The holder class for the current scroll position. */
private val scrollPosition =
GlimmerListScrollPosition(firstVisibleItemIndex, firstVisibleItemScrollOffset)
/** Backing state for [layoutInfo] */
internal val layoutInfoState = mutableStateOf(EmptyLazyListMeasureResult, neverEqualPolicy())
private val density: Density
get() = layoutInfoState.value.density
/**
* This field is used to save information about the number of "beyond bounds items" that we want
* to compose. These items are not within the visible bounds of the lazy layout, but we compose
* them because they are explicitly requested through the
* [beyond bounds layout API][androidx.compose.ui.layout.BeyondBoundsLayout].
*/
internal val beyondBoundsInfo = LazyLayoutBeyondBoundsInfo()
/** Includes information for requesting focus for children as the list scrolls. */
internal val autoFocusState = GlimmerListAutoFocusState()
/** Stores currently pinned items which are always composed. */
internal val pinnedItems = LazyLayoutPinnedItemList()
internal val internalInteractionSource: MutableInteractionSource = MutableInteractionSource()
/**
* The scroll amount was provided in `onScroll` to be consumed during the current measure pass.
*
* Scrolling forward is negative.
*/
internal var incomingScroll: Float = 0f
private set
/**
* This value retains the scroll amount that we want to carry over between measure passes. It
* represents the amount of scroll from previous passes that wasn't consumed back then but is
* awaiting to be consumed later. Together with [incomingScroll], this represents the total
* scroll available to the list during the current measure pass.
*
* This variable exists because the layout uses integer pixels while [onScroll] operates with
* fractional floats. Consequently, the list might receive tiny fractions of scroll input which,
* when aggregated, should equate to a few pixels of scrolling. However, because these small
* fractions are spread across many [onScroll] invocations, the list ignores them, making it
* feel unresponsive during slow, gentle touches. To avoid this, instead of ignoring these tiny
* scroll increments, the list accumulates them so they can be added to the next scroll part and
* actually consumed in a subsequent pass.
*
* Note that this value can sometimes be opposite to the scroll direction. This occurs because
* the [Float] -> [Int] rounding causes us to consume more than was provided. To compensate, we
* add a negative value to [incomingScroll] the next time.
*
* Scrolling forward is negative.
*/
internal var carryOverScroll: Float = 0f
private set
/**
* This value is updated after the measure pass inside [applyMeasureResult] and defines how much
* of the [incomingScroll] was actually used.
*/
private var consumedScroll: Float = 0f
internal val nearestRange: kotlin.ranges.IntRange by
LazyLayoutNearestRangeState(0, NearestItemsSlidingWindowSize, NearestItemsExtraItemCount)
/**
* The [Remeasurement] object associated with our layout. It allows us to remeasure
* synchronously during scroll.
*/
internal var remeasurement: Remeasurement? = null
private set
/** The modifier which provides [remeasurement]. */
internal val remeasurementModifier =
object : RemeasurementModifier {
override fun onRemeasurementAvailable(remeasurement: Remeasurement) {
this@ListState.remeasurement = remeasurement
}
}
/**
* Provides a modifier which allows to delay some interactions (e.g. scroll) until layout is
* ready.
*/
internal val awaitLayoutModifier = AwaitFirstLayoutModifier()
/**
* The index of the first item that is visible within the scrollable viewport area not including
* items in the content padding region. For the first visible item that includes items in the
* content padding please use [ListLayoutInfo.visibleItemsInfo].
*
* Note that this property is observable and if you use it in the composable function it will be
* recomposed on every change causing potential performance issues.
*/
public val firstVisibleItemIndex: Int
@FrequentlyChangingValue get() = scrollPosition.index
/**
* The scroll offset of the first visible item. Scrolling forward is positive - i.e., the amount
* that the item is offset backwards.
*
* Note that this property is observable and if you use it in the composable function it will be
* recomposed on every scroll causing potential performance issues.
*/
public val firstVisibleItemScrollOffset: Int
@FrequentlyChangingValue get() = scrollPosition.scrollOffset
/**
* The object of [ListLayoutInfo] calculated during the last layout pass. For example, you can
* use it to calculate what items are currently visible.
*
* Note that this property is observable and is updated after every scroll or remeasure. If you
* use it in the composable function it will be recomposed on every change causing potential
* performance issues including infinity recomposition loop. Therefore, avoid using it in the
* composition.
*
* If you want to run some side effects like sending an analytics event or updating a state
* based on this value consider using "snapshotFlow":
*/
public val layoutInfo: ListLayoutInfo
@FrequentlyChangingValue get() = layoutInfoState.value
/**
* [InteractionSource] that will be used to dispatch drag events when this list is being
* dragged. If you want to know whether the fling (or animated scroll) is in progress, use
* [isScrollInProgress].
*/
public val interactionSource: InteractionSource
get() = internalInteractionSource
/** Snaps to the requested scroll position. */
internal fun snapToItemIndexInternal(index: Int, scrollOffset: Int) {
scrollPosition.requestPositionAndForgetLastKnownKey(index, scrollOffset)
remeasurement?.forceRemeasure()
}
/**
* When the user provided custom keys for the items we can try to detect when there were items
* added or removed before our current first visible item and keep this item as the first
* visible one even given that its index has been changed.
*/
internal fun updateScrollPositionIfTheFirstItemWasMoved(
itemProvider: GlimmerListItemProvider,
firstItemIndex: Int,
): Int = scrollPosition.updateScrollPositionIfTheFirstItemWasMoved(itemProvider, firstItemIndex)
/**
* Called during the measurement pass once the dispatched [incomingScroll] has been handled and
* the new item positions are known.
*
* @param result lazy list measuring results.
* @param consumedScroll defines how much scroll was consumed during the measure pass.
* @param scrollToCarryOver tracks the amount of scrolling that the internal logic has reported
* as consumed, but wants to save for later measurement. Also, if the list consumes more
* scroll than it was given (for example, due to rounding errors), this value can be set to a
* negative amount to balance it out in the next pass. This value should never be larger than
* [consumedScroll].
*/
internal fun applyMeasureResult(
result: GlimmerListMeasureResult,
consumedScroll: Float,
scrollToCarryOver: Float,
) {
this.consumedScroll = consumedScroll
this.carryOverScroll = scrollToCarryOver
canScrollBackward = result.canScrollBackward
canScrollForward = result.canScrollForward
layoutInfoState.value = result
scrollPosition.updateFromMeasureResult(result)
}
internal fun onScroll(distance: Float): Float {
if (distance < 0 && !canScrollForward || distance > 0 && !canScrollBackward) {
return 0f
}
// Fast path. Skip measure pass.
if (abs(distance + carryOverScroll) <= 0.5f) {
// Inside measuring we do `scrollToBeConsumed.roundToInt()` so there will be no scroll
// if we have less than 0.5 pixels. So just accumulate it for the next pass.
carryOverScroll += distance
return distance
}
incomingScroll = distance
// The `forceRemeasure()` invocation triggers the measure pass where [incomingScroll]
// will be used to update [consumedScroll] and [carryOverScroll] values.
remeasurement?.forceRemeasure()
// It's important to reset this value because there are measure passes
// triggered from outside scrolling. They read this value as well.
// So, after we used it, we need to reset it to zero.
incomingScroll = 0f
return consumedScroll
}
override suspend fun scroll(
scrollPriority: MutatePriority,
block: suspend ScrollScope.() -> Unit,
) {
awaitLayoutModifier.waitForFirstLayout()
backingState.scroll(scrollPriority, block)
}
override fun dispatchRawDelta(delta: Float): Float = backingState.dispatchRawDelta(delta)
override val isScrollInProgress: Boolean
get() = backingState.isScrollInProgress
@get:Suppress("GetterSetterNames")
override var canScrollForward: Boolean by mutableStateOf(false)
private set
@get:Suppress("GetterSetterNames")
override var canScrollBackward: Boolean by mutableStateOf(false)
private set
override val scrollIndicatorState: ScrollIndicatorState
get() = _scrollIndicatorState
private val _scrollIndicatorState =
object : ScrollIndicatorState {
override val scrollOffset: Int
@FrequentlyChangingValue
get() =
with(layoutInfoState.value) {
if (this === EmptyLazyListMeasureResult) {
Int.MAX_VALUE
} else {
this@ListState.firstVisibleItemIndex * visibleItemsAverageSize +
this@ListState.firstVisibleItemScrollOffset
}
}
override val contentSize: Int
@FrequentlyChangingValue
get() =
with(layoutInfoState.value) {
if (this === EmptyLazyListMeasureResult) {
Int.MAX_VALUE
} else {
// Approximate size of all content
(totalItemsCount * visibleItemsAverageSize) -
// Subtract the final trailing spacing that is not shown
(if (totalItemsCount > 0) mainAxisItemSpacing else 0) +
// Add the inner paddings (which are separate from the item sizes)
beforeContentPadding +
afterContentPadding
}
}
override val viewportSize: Int
get() =
with(layoutInfoState.value) {
if (this === EmptyLazyListMeasureResult) {
Int.MAX_VALUE
} else {
mainAxisViewportSize
}
}
}
/**
* Instantly brings the item at [index] to the top of the viewport, offset by [scrollOffset]
* pixels.
*
* @param index the index to which to scroll. Must be non-negative.
* @param scrollOffset the offset that the item should end up after the scroll. Note that
* positive offset refers to forward scroll, so in a top-to-bottom list, positive offset will
* scroll the item further upward (taking it partly offscreen).
*/
public suspend fun scrollToItem(@IntRange(from = 0) index: Int, scrollOffset: Int = 0) {
scroll { snapToItemIndexInternal(index, scrollOffset) }
}
/**
* Animate (smooth scroll) to the given item.
*
* @param index the index to which to scroll. Must be non-negative.
* @param scrollOffset the offset that the item should end up after the scroll. Note that
* positive offset refers to forward scroll, so in a top-to-bottom list, positive offset will
* scroll the item further upward (taking it partly offscreen).
*/
public suspend fun animateScrollToItem(@IntRange(from = 0) index: Int, scrollOffset: Int = 0) {
scroll {
GlimmerListScrollScope(this@ListState, this)
.animateScrollToItem(index, scrollOffset, NumberOfItemsToTeleport, density)
}
}
public companion object {
/** The default [Saver] implementation for [ListState]. */
public val Saver: Saver<ListState, Any> =
listSaver(
save = { listOf(it.firstVisibleItemIndex, it.firstVisibleItemScrollOffset) },
restore = {
ListState(firstVisibleItemIndex = it[0], firstVisibleItemScrollOffset = it[1])
},
)
}
}
private val EmptyLazyListMeasureResult =
GlimmerListMeasureResult(
firstVisibleItem = null,
firstVisibleItemScrollOffset = 0,
canScrollForward = false,
consumedScroll = 0f,
measureResult =
object : MeasureResult {
override val width: Int = 0
override val height: Int = 0
@Suppress("PrimitiveInCollection")
override val alignmentLines: Map<AlignmentLine, Int> = emptyMap()
override fun placeChildren() {}
},
visibleItemsInfo = emptyList(),
viewportStartOffset = 0,
viewportEndOffset = 0,
totalItemsCount = 0,
reverseLayout = false,
orientation = Orientation.Vertical,
afterContentPadding = 0,
mainAxisItemSpacing = 0,
remeasureNeeded = false,
density = Density(1f),
childConstraints = Constraints(),
)
```
<br />