diff --git a/build/agp/agp-9-upgrade/SKILL.md b/build/agp/agp-9-upgrade/SKILL.md index 1ffa0db..280347a 100644 --- a/build/agp/agp-9-upgrade/SKILL.md +++ b/build/agp/agp-9-upgrade/SKILL.md @@ -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 diff --git a/build/agp/agp-9-upgrade/references/android/build/migrate-to-built-in-kotlin.md b/build/agp/agp-9-upgrade/references/android/build/migrate-to-built-in-kotlin.md new file mode 100644 index 0000000..7306e7b --- /dev/null +++ b/build/agp/agp-9-upgrade/references/android/build/migrate-to-built-in-kotlin.md @@ -0,0 +1,416 @@ +Android Gradle plugin 9.0 introduces built-in Kotlin support and enables it +by default. That means you no longer have to apply the +`org.jetbrains.kotlin.android` (or `kotlin-android`) plugin in your build files +to compile Kotlin source files. +With built-in Kotlin, your build files are simpler and you can avoid +compatibility issues between AGP and the `kotlin-android` plugin. + +> [!NOTE] +> **Note:** Built-in Kotlin replaces the `kotlin-android` plugin only. If you are writing a Kotlin Multiplatform (KMP) library module, you still need to apply the `org.jetbrains.kotlin.multiplatform` plugin and the [`com.android.kotlin.multiplatform.library`](https://developer.android.com/kotlin/multiplatform/plugin) plugin. Also, using the `org.jetbrains.kotlin.multiplatform` plugin together with the `com.android.library` or `com.android.application` plugin is no longer allowed when built-in Kotlin is enabled. + +## Enable built-in Kotlin + +You need AGP 9.0 or higher to have built-in Kotlin support. +AGP 9.0 already enables built-in Kotlin for all your modules where you apply +AGP, so you don't need to do anything to enable it. However, if you previously +[opted out of built-in Kotlin](https://developer.android.com/build/migrate-to-built-in-kotlin#opt-out-of-built-in-kotlin) by setting `android.builtInKotlin=false` +in the `gradle.properties` file, you need to remove that setting or set it to +`true`. + +> [!NOTE] +> **Note:** You can also enable built-in Kotlin for [one module at a time](https://developer.android.com/build/migrate-to-built-in-kotlin#module-by-module-migration). + +Built-in Kotlin requires some changes to your project, so after you +have built-in Kotlin enabled, follow the next steps to migrate your project. + +## Migration steps + +After you upgrade your project from an older AGP version to AGP 9.0 or after +you manually [enable built-in Kotlin](https://developer.android.com/build/migrate-to-built-in-kotlin#enable-built-in-kotlin), you might see the following error +message: + + Failed to apply plugin 'org.jetbrains.kotlin.android'. + > Cannot add extension with name 'kotlin', as there is an extension already registered with that name. + +...or + + Failed to apply plugin 'com.jetbrains.kotlin.android' + > The 'org.jetbrains.kotlin.android' plugin is no longer required for Kotlin support since AGP 9.0. + +This error occurs because built-in Kotlin requires some changes to your project. +To resolve this error, follow these steps: + +> [!NOTE] +> **Note:** If you're not yet ready to migrate your project, you can also [opt out of built-in Kotlin](https://developer.android.com/build/migrate-to-built-in-kotlin#opt-out-of-built-in-kotlin). + +1. [Remove the `kotlin-android` plugin](https://developer.android.com/build/migrate-to-built-in-kotlin#migration-steps-remove-kotlin-android-plugin) +2. [Migrate the `kotlin-kapt` plugin if necessary](https://developer.android.com/build/migrate-to-built-in-kotlin#migration-steps-migrate-kotlin-kapt-plugin) +3. [Migrate the `android.kotlinOptions{}` DSL if necessary](https://developer.android.com/build/migrate-to-built-in-kotlin#migration-steps-migrate-kotlin-options) +4. [Migrate the `kotlin.sourceSets{}` DSL if necessary](https://developer.android.com/build/migrate-to-built-in-kotlin#migration-steps-migrate-kotlin-source-sets) + +### 1. Remove the `kotlin-android` plugin + +Remove the `org.jetbrains.kotlin.android` (or `kotlin-android`) plugin from +the module-level build files where you apply it. +The exact code to remove depends on +whether you use [version catalogs](https://docs.gradle.org/current/userguide/version_catalogs.html) to declare plugins. + +### With version catalogs + +### Kotlin + +```kotlin +// Module-level build file +plugins { + alias(libs.plugins.kotlin.android) +} +``` + +### Groovy + +```groovy +// Module-level build file +plugins { + alias(libs.plugins.kotlin.android) +} +``` + +### No version catalogs + +### Kotlin + +```kotlin +// Module-level build file +plugins { + id("org.jetbrains.kotlin.android") +} +``` + +### Groovy + +```groovy +// Module-level build file +plugins { + id 'org.jetbrains.kotlin.android' +} +``` + +Then, remove the plugin from your top-level build file: + +### With version catalogs + +### Kotlin + +```kotlin +// Top-level build file +plugins { + alias(libs.plugins.kotlin.android) apply false +} +``` + +### Groovy + +```groovy +// Top-level build file +plugins { + alias(libs.plugins.kotlin.android) apply false +} +``` + +### No version catalogs + +### Kotlin + +```kotlin +// Top-level build file +plugins { + id("org.jetbrains.kotlin.android") version "KOTLIN_VERSION" apply false +} +``` + +### Groovy + +```groovy +// Top-level build file +plugins { + id 'org.jetbrains.kotlin.android' version 'KOTLIN_VERSION' apply false +} +``` + +If you use version catalogs, also remove the plugin definition from the +version catalog TOML file (usually `gradle/libs.versions.toml`): + +```toml +[plugins] +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "KOTLIN_VERSION" } +``` + +### 2. Migrate the `kotlin-kapt` plugin if necessary + +The `org.jetbrains.kotlin.kapt` (or `kotlin-kapt`) plugin is incompatible with +built-in Kotlin. If you use `kapt`, we recommend that you +[migrate your project to KSP](https://developer.android.com/build/migrate-to-ksp). + +If you can't migrate to KSP yet, replace the `kotlin-kapt` plugin with the +`com.android.legacy-kapt` plugin, using the same version as your Android Gradle +plugin. + +For example, with version catalogs, update your version catalog TOML +file as follows: + +```toml +[plugins] +android-application = { id = "com.android.application", version.ref = "AGP_VERSION" } + +# Add the following plugin definition +legacy-kapt = { id = "com.android.legacy-kapt", version.ref = "AGP_VERSION" } + +# Remove the following plugin definition +kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "KOTLIN_VERSION" } +``` + +Then, update your build files: + +### Kotlin + +```kotlin +// Top-level build file +plugins { + alias(libs.plugins.legacy.kapt) apply false + alias(libs.plugins.kotlin.kapt) apply false +} +``` + +### Groovy + +```groovy +// Top-level build file +plugins { + alias(libs.plugins.legacy.kapt) apply false + alias(libs.plugins.kotlin.kapt) apply false +} +``` + +### Kotlin + +```kotlin +// Module-level build file +plugins { + alias(libs.plugins.legacy.kapt) + alias(libs.plugins.kotlin.kapt) +} +``` + +### Groovy + +```groovy +// Module-level build file +plugins { + alias(libs.plugins.legacy.kapt) + alias(libs.plugins.kotlin.kapt) +} +``` + +> [!NOTE] +> **Note:** If you declare the `kotlin-kapt` plugin in the `plugins{}` block as `kotlin("kapt") version ""`, then remove that line instead. + +### 3. Migrate the `android.kotlinOptions{}` DSL if necessary + +If you use the `android.kotlinOptions{}` DSL, you need to +migrate it to the [`kotlin.compilerOptions{}`](https://kotlinlang.org/docs/gradle-compiler-options.html#migrate-from-kotlinoptions-to-compileroptions) DSL. + +For example, update this code: + +### Kotlin + +```kotlin +android { + kotlinOptions { + languageVersion = "2.0" + jvmTarget = "11" + } +} +``` + +### Groovy + +```groovy +android { + kotlinOptions { + languageVersion = "2.0" + jvmTarget = "11" + } +} +``` + +...to the new DSL: + +### Kotlin + +```kotlin +kotlin { + compilerOptions { + languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_0 + // Optional: Set jvmTarget + // jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 + } +} +``` + +### Groovy + +```groovy +kotlin { + compilerOptions { + languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_0 + // Optional: Set jvmTarget + // jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 + } +} +``` + +> [!NOTE] +> **Note:** With built-in Kotlin, you don't need to set `kotlin.compilerOptions.jvmTarget` because its value defaults to `android.compileOptions.targetCompatibility`. + +### 4. Migrate the `kotlin.sourceSets{}` DSL if necessary + +When you use the `kotlin-android` plugin, AGP lets you add additional Kotlin +source directories using either the [`android.sourceSets{}`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/dsl/AndroidSourceSet) DSL or the +[`kotlin.sourceSets{}`](https://kotlinlang.org/api/kotlin-gradle-plugin/kotlin-gradle-plugin-api/org.jetbrains.kotlin.gradle.plugin/-kotlin-source-set/) DSL. +With the `android.sourceSets{}` DSL, you can add the directories to either the +`AndroidSourceSet.kotlin` set or the `AndroidSourceSet.java` set. + +With built-in Kotlin, the only supported option is to add the directories to the +`AndroidSourceSet.kotlin` set using the `android.sourceSets{}` DSL. +If you use unsupported options, migrate them as follows: + +### Kotlin + +```kotlin +# Adding Kotlin source directories to kotlin.sourceSets is not supported +kotlin.sourceSets.named("main") { + kotlin.srcDir("additionalSourceDirectory/kotlin") +} + +# Adding Kotlin source directories to AndroidSourceSet.java is also not supported +android.sourceSets.named("main") { + java.directories += "additionalSourceDirectory/kotlin" +} + +# Add Kotlin source directories to AndroidSourceSet.kotlin +android.sourceSets.named("main") { + kotlin.directories += "additionalSourceDirectory/kotlin" +} +``` + +### Groovy + +```groovy +# Adding Kotlin source directories to kotlin.sourceSets is not supported +kotlin.sourceSets.named("main") { + kotlin.srcDir("additionalSourceDirectory/kotlin") +} + +# Adding Kotlin source directories to AndroidSourceSet.java is also not supported +android.sourceSets.named("main") { + java.directories.add("additionalSourceDirectory/kotlin") +} + +# Add Kotlin source directories to AndroidSourceSet.kotlin +android.sourceSets.named("main") { + kotlin.directories.add("additionalSourceDirectory/kotlin") +} +``` + +If you want to add a Kotlin source directory to a specific variant or if the +directory is generated by a task, you can use the +[`addStaticSourceDirectory`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/variant/SourceDirectories#addStaticSourceDirectory(kotlin.String)) or [`addGeneratedSourceDirectory`](https://developer.android.com/reference/tools/gradle-api/9.0/com/android/build/api/variant/SourceDirectories#addGeneratedSourceDirectory(org.gradle.api.tasks.TaskProvider,kotlin.Function1)) methods +in the [variant API](https://developer.android.com/build/extend-agp#variant-api-artifacts-tasks): + +### Kotlin + +```kotlin +androidComponents.onVariants { variant -> + variant.sources.kotlin!!.addStaticSourceDirectory("additionalSourceDirectory/kotlin") + variant.sources.kotlin!!.addGeneratedSourceDirectory(TASK_PROVIDER, TASK_OUTPUT) +} +``` + +### Groovy + +```groovy +androidComponents.onVariants { variant -> + variant.sources.kotlin!!.addStaticSourceDirectory("additionalSourceDirectory/kotlin") + variant.sources.kotlin!!.addGeneratedSourceDirectory(TASK_PROVIDER, TASK_OUTPUT) +} +``` + +## Report issues + +If you encounter issues after completing the previous steps, +review the known issues in [issue #438678642](https://issuetracker.google.com/438678642) and give us +feedback if needed. + +## Opt out of built-in Kotlin + +If you are unable to migrate your project to use built-in Kotlin, set +`android.builtInKotlin=false` in the `gradle.properties` file to temporarily +disable it. +When you do that, the build shows a warning reminding you to migrate to built-in +Kotlin as you won't be able to disable built-in Kotlin in AGP 10.0. + +> [!NOTE] +> **Note:** You also need to set `android.newDsl=false` to opt out of the [new DSL](https://developer.android.com/r/tools/new-dsl) because the `kotlin-android` plugin is not compatible with it. + +Once you're ready to migrate your project, [enable built-in Kotlin](https://developer.android.com/build/migrate-to-built-in-kotlin#enable-built-in-kotlin) +and follow the [migration steps](https://developer.android.com/build/migrate-to-built-in-kotlin#migration-steps). + +## Module-by-module migration + +The `android.builtInKotlin` Gradle property lets you enable or disable built-in +Kotlin for all your modules where you apply AGP. + +If migrating all your modules at once is challenging, you can migrate one module +at a time: + +1. Set `android.builtInKotlin=false` in the `gradle.properties` file to + disable built-in Kotlin for all modules. + +2. Apply the `com.android.built-in-kotlin` plugin to the module + you want to enable built-in Kotlin, using the same version as your + Android Gradle plugin. + +3. Follow the previous [migration steps](https://developer.android.com/build/migrate-to-built-in-kotlin#migration-steps) to migrate this module to + built-in Kotlin. + +4. Once you've migrated all your modules, remove the + `android.builtInKotlin=false` setting in `gradle.properties` + and the `com.android.built-in-kotlin` plugin in your build files. + +## Option to selectively disable built-in Kotlin + +Android Gradle plugin 9.0 enables built-in Kotlin for all modules where it is +applied. +We recommend disabling built-in Kotlin selectively for modules that don't have +Kotlin sources in large projects. +This removes both the Kotlin compilation task, which has a small build +performance cost, and the automatic dependency on the Kotlin standard library. + +To disable built-in Kotlin for a module, +set `enableKotlin = false` in that module's build file: + +### Kotlin + +```kotlin +android { + enableKotlin = false +} +``` + +### Groovy + +```groovy +android { + enableKotlin = false +} +``` \ No newline at end of file diff --git a/camera/camera1-to-camerax/SKILL.md b/camera/camera1-to-camerax/SKILL.md deleted file mode 100644 index b47f97c..0000000 --- a/camera/camera1-to-camerax/SKILL.md +++ /dev/null @@ -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 = "" - -[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" } -``` - -
- -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) -``` - -
- -Without a Version Catalog, fall back to these standard Gradle dependencies: - - -```kotlin -implementation "androidx.camera:camera-core:" -implementation "androidx.camera:camera-camera2:" -implementation "androidx.camera:camera-lifecycle:" -implementation "androidx.camera:camera-view:" -implementation "androidx.camera:camera-compose:" -``` - -
- -## 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) - ) -} -``` - -
- -## 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) -``` - -
- -**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) -``` - -
- -### Option B: For Jetpack Compose - -Use `androidx.camera.compose.CameraXViewfinder`. - -**1. Set up preview and SurfaceRequest**: - - -```kotlin -var surfaceRequest by remember { mutableStateOf(null) } -val preview = remember { - Preview.Builder().build().apply { - setSurfaceProvider { request -> surfaceRequest = request } - } -} -``` - -
- -**2. Render viewfinder**: - - -```kotlin -surfaceRequest?.let { request -> - CameraXViewfinder( - surfaceRequest = request, - coordinateTransformer = coordinateTransformer, - modifier = Modifier - ) -} -``` - -
- -**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) -``` - -
- -**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 - } -} -``` - -
- -## 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) - } - } -) -``` - -
- -## 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 -} -``` - -
- -## 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. diff --git a/camera/camerax/SKILL.md b/camera/camerax/SKILL.md new file mode 100644 index 0000000..e6eebe1 --- /dev/null +++ b/camera/camerax/SKILL.md @@ -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) +} +``` + +
+ +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`. diff --git a/camera/camerax/references/expert-blueprints.md b/camera/camerax/references/expert-blueprints.md new file mode 100644 index 0000000..e1f2f25 --- /dev/null +++ b/camera/camerax/references/expert-blueprints.md @@ -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` 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). \ No newline at end of file diff --git a/camera/camerax/references/foldables.md b/camera/camerax/references/foldables.md new file mode 100644 index 0000000..22d378a --- /dev/null +++ b/camera/camerax/references/foldables.md @@ -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() + .firstOrNull() + + updateCameraLayout(displayFeature) + } + } +} +``` + +
+ +### 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() +``` + +
+ +### 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. \ No newline at end of file diff --git a/camera/camerax/references/immutability.md b/camera/camerax/references/immutability.md new file mode 100644 index 0000000..1dade21 --- /dev/null +++ b/camera/camerax/references/immutability.md @@ -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) +} +``` + +
+ +### 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() +``` + +
\ No newline at end of file diff --git a/camera/camerax/references/low-light.md b/camera/camerax/references/low-light.md new file mode 100644 index 0000000..e03e00c --- /dev/null +++ b/camera/camerax/references/low-light.md @@ -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) +} +``` + +
+ +### 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. \ No newline at end of file diff --git a/camera/camerax/references/mlkit-spatial.md b/camera/camerax/references/mlkit-spatial.md new file mode 100644 index 0000000..833daf1 --- /dev/null +++ b/camera/camerax/references/mlkit-spatial.md @@ -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) +} +``` + +
+ +### 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 +``` + +
+ +**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`. \ No newline at end of file diff --git a/camera/camerax/references/modern-apis.md b/camera/camerax/references/modern-apis.md new file mode 100644 index 0000000..616d549 --- /dev/null +++ b/camera/camerax/references/modern-apis.md @@ -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. \ No newline at end of file diff --git a/camera/camerax/references/testing.md b/camera/camerax/references/testing.md new file mode 100644 index 0000000..972f285 --- /dev/null +++ b/camera/camerax/references/testing.md @@ -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 +``` + +
+ +### 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) +``` + +
+ +### 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. \ No newline at end of file diff --git a/camera/camerax/references/thermals.md b/camera/camerax/references/thermals.md new file mode 100644 index 0000000..41cfe19 --- /dev/null +++ b/camera/camerax/references/thermals.md @@ -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() +``` + +
+ +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 + } + } +} +``` + +
+ +### 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`. \ No newline at end of file diff --git a/camera/camerax/references/wear-os.md b/camera/camerax/references/wear-os.md new file mode 100644 index 0000000..4a942db --- /dev/null +++ b/camera/camerax/references/wear-os.md @@ -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()) +} +``` + +
+ +**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) +``` + +
+ +### 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. \ No newline at end of file diff --git a/camera/camerax/references/xr.md b/camera/camerax/references/xr.md new file mode 100644 index 0000000..59fae32 --- /dev/null +++ b/camera/camerax/references/xr.md @@ -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) +``` + +
+ +### 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. \ No newline at end of file diff --git a/identity/verified-email/SKILL.md b/identity/verified-email/SKILL.md index 31d185f..784038e 100644 --- a/identity/verified-email/SKILL.md +++ b/identity/verified-email/SKILL.md @@ -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 diff --git a/identity/verified-email/references/android/identity/digital-credentials/email-verification-implementation.md b/identity/verified-email/references/android/identity/digital-credentials/email-verification-implementation.md index e34dc18..9305e92 100644 --- a/identity/verified-email/references/android/identity/digital-credentials/email-verification-implementation.md +++ b/identity/verified-email/references/android/identity/digital-credentials/email-verification-implementation.md @@ -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 diff --git a/jetpack-compose/adaptive/SKILL.md b/jetpack-compose/adaptive/SKILL.md index 26e9f87..566374a 100644 --- a/jetpack-compose/adaptive/SKILL.md +++ b/jetpack-compose/adaptive/SKILL.md @@ -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 diff --git a/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/get-started.md b/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/get-started.md index f1122fe..8b00b01 100644 --- a/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/get-started.md +++ b/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/get-started.md @@ -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" } diff --git a/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/container-properties.md b/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/container-properties.md index 7625935..33b37a2 100644 --- a/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/container-properties.md +++ b/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/container-properties.md @@ -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)") +} +``` + +
+ +![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") + } + } +} +``` + +
+ +![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(
-![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(
-![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, diff --git a/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/get-started.md b/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/get-started.md index e47dced..bb78a97 100644 --- a/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/get-started.md +++ b/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/get-started.md @@ -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" } diff --git a/jetpack-compose/adaptive/references/android/develop/ui/compose/tooling/debug.md b/jetpack-compose/adaptive/references/android/develop/ui/compose/tooling/debug.md index 77bc9da..023836f 100644 --- a/jetpack-compose/adaptive/references/android/develop/ui/compose/tooling/debug.md +++ b/jetpack-compose/adaptive/references/android/develop/ui/compose/tooling/debug.md @@ -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 diff --git a/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/SKILL.md b/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/SKILL.md index 58a84c7..ea25f8e 100644 --- a/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/SKILL.md +++ b/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/SKILL.md @@ -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 diff --git a/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/analysis-of-the-project-and-layout.md b/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/analysis-of-the-project-and-layout.md new file mode 100644 index 0000000..3e2f0c3 --- /dev/null +++ b/jetpack-compose/migration/migrate-xml-views-to-jetpack-compose/references/analysis-of-the-project-and-layout.md @@ -0,0 +1,42 @@ +## 1. Project health \& build validation + +Before performing any analysis, you must confirm the project is in a functional state. +\* **Integrity check:** Verify the project syncs (Gradle) and builds successfully. +\* **Error resolution:** If there are pre-existing build errors or sync failures, you must report these immediately and attempt to fix. **Do not proceed** with migration until a stable baseline is established. + +## 2. Compose pattern \& consistency analysis + +If Jetpack Compose is already present, you must align with the established implementation style. +\* **Pattern identification:** Scan the codebase for `@Composable` functions. Identify the project's "Best Practices" regarding state hoisting, composable construction and naming conventions, and file organization. +\* **Theming review:** Determine how `MaterialTheme` or custom theme systems are implemented. +\* Identify if the project uses a custom design system theme. +\* Map how attributes, styles, and other theme components are accessed in Compose. + +## 3. Design system \& infrastructure audit + +Understand the design system classification (e.g. Material 2, Material 3, or custom design system). +\* **Resource mapping:** Locate central XML definitions: +\* `colors.xml` (Light/Dark variants) +\* `dimens.xml` +\* `styles.xml` / `themes.xml` +\* **Hybrid analysis:** Determine if the project is **XML-only** , **Compose-only** , or **Hybrid** . +\* **Reuse constraint:** If a Compose theming layer (e.g., `AppTheme.kt`) already exists, **DO NOT** generate a new one. You must reuse the existing infrastructure and contribute to it by following its existing implementation pattern. + +## 4. Candidate layout decomposition + +Analyze the specific XML layout targeted for migration. You must extract and document the following requirements for the new composable: +\* **Inputs:** UI State objects, primitive parameters, and click listeners. +\* **Styling:** Specific color constants, typography styles, and shape definitions referenced in the XML. +\* **Resources:** Identifying string resources, drawables, and dimensions. +\* **Layout logic:** Modifiers required to replicate the XML constraints (padding, alignment, weight). + +## 5. Architectural \& non-UI analysis + +Understand the environment in which the UI resides to ensure proper integration. +\* **State management:** Identify the usage of `ViewModel`, `Flow`, or `LiveData`. +\* **Dependency Injection:** Check for Hilt, Koin, or manual DI to understand how dependencies are provided to the UI layer. +\* **Testing \& architecture:** Note the architectural pattern (MVI, MVVM, or custom architecture setup.) and existing UI testing frameworks to ensure the migrated code remains testable. Unless the user explicitly requests, **DO NOT** make any changes to any non-UI code that aren't strictly required for the migration of the XML View. + +*** ** * ** *** + +> **Pro-tip:** Always prioritize the "Existing infrastructure" over "Default templates." If the project has a custom way of handling spacing or colors, composable code, or any other project layer, your generated Compose code must reflect that specific implementation. \ No newline at end of file diff --git a/jetpack-compose/theming/styles/SKILL.md b/jetpack-compose/theming/styles/SKILL.md index 8a82ccd..daf7256 100644 --- a/jetpack-compose/theming/styles/SKILL.md +++ b/jetpack-compose/theming/styles/SKILL.md @@ -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`. diff --git a/navigation/navigation-3/SKILL.md b/navigation/navigation-3/SKILL.md index 87ccd8b..3704be5 100644 --- a/navigation/navigation-3/SKILL.md +++ b/navigation/navigation-3/SKILL.md @@ -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 diff --git a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/basicdsl.md b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/basicdsl.md new file mode 100644 index 0000000..2067c08 --- /dev/null +++ b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/basicdsl.md @@ -0,0 +1,85 @@ +# Basic DSL Recipe + +This recipe shows a basic example of how to use the Navigation 3 API with two screens, using the `entryProvider` DSL and a persistent back stack. + +## How it works + +This example is similar to the basic recipe, but with a few key differences: + +1. **Persistent Back Stack** : It uses `rememberNavBackStack(RouteA)` to create and remember the back stack. This makes the back stack persistent across configuration changes (e.g., screen rotation). To use `rememberNavBackStack`, the navigation keys must be serializable, which is why `RouteA` and `RouteB` are annotated with `@Serializable` and implement the `NavKey` interface. + +2. **`entryProvider` DSL** : Instead of a `when` statement, this example uses the `entryProvider` DSL to define the content for each route. The `entry` function is used to associate a route type with its composable content. + +The navigation logic remains the same: to navigate from `RouteA` to `RouteB`, we add a `RouteB` instance to the back stack. +[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/basicdsl) + +``` +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.nav3recipes.basicdsl + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.content.ContentBlue +import com.example.nav3recipes.content.ContentGreen +import com.example.nav3recipes.ui.setEdgeToEdgeConfig +import kotlinx.serialization.Serializable + +@Serializable +private data object RouteA : NavKey + +@Serializable +private data class RouteB(val id: String) : NavKey + +class BasicDslActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + setEdgeToEdgeConfig() + super.onCreate(savedInstanceState) + setContent { + val backStack = rememberNavBackStack(RouteA) + + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryProvider = entryProvider { + entry { + ContentGreen("Welcome to Nav3") { + Button(onClick = dropUnlessResumed { + backStack.add(RouteB("123")) + }) { + Text("Click to navigate") + } + } + } + entry { key -> + ContentBlue("Route id: ${key.id} ") + } + } + ) + } + } +} +``` \ No newline at end of file diff --git a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-basic.md b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-basic.md new file mode 100644 index 0000000..66312de --- /dev/null +++ b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-basic.md @@ -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> = 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 = rememberNavBackStack(key) + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryProvider = entryProvider { + entry { key -> + EntryScreen(key.name) { + TextContent("") + } + } + entry { key -> + EntryScreen("${key.name} : ${key.filter}") { + TextContent("") + val list = when { + key.filter.isEmpty() -> LIST_USERS + key.filter == UsersKey.FILTER_OPTION_ALL -> LIST_USERS + else -> LIST_USERS.take(5) + } + FriendsList(list) + } + } + entry { search -> + EntryScreen(search.name) { + TextContent("") + val matchingUsers = LIST_USERS.filter { user -> + (search.firstName == null || user.firstName == search.firstName) && + (search.location == null || user.location == search.location) && + (search.ageMin == null || user.age >= search.ageMin) && + (search.ageMax == null || user.age <= search.ageMax) + } + FriendsList(matchingUsers) + } + } + } + ) + } + } +} +``` + +``` +package com.example.nav3recipes.deeplink.basic + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.core.net.toUri +import androidx.lifecycle.compose.dropUnlessResumed +import com.example.nav3recipes.common.deeplink.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() } + + // manage path options + MenuDropDown( + menuOptions = MENU_OPTIONS_PATH, + ) { _, selection -> + selectedPath.value = selection + when (selection) { + PATH_SEARCH -> { + showQueryOptions = true + showFilterOptions = false + } + + PATH_INCLUDE -> { + showQueryOptions = false + showFilterOptions = true + } + + else -> { + showQueryOptions = false + showFilterOptions = false + } + } + } + + // manage path filter options, reset state if menu is closed + LaunchedEffect(showFilterOptions) { + selectedFilter = if (showFilterOptions) { + MENU_OPTIONS_FILTER.values.first().first() + } else { + "" + } + } + if (showFilterOptions) { + MenuDropDown( + menuOptions = MENU_OPTIONS_FILTER, + ) { _, selected -> + selectedFilter = selected + } + } + + // manage query options, reset state if menu is closed + LaunchedEffect(showQueryOptions) { + if (showQueryOptions) { + val initEntry = MENU_OPTIONS_SEARCH.entries.first() + selectedSearchQuery[initEntry.key] = initEntry.value.first() + } else { + selectedSearchQuery.clear() + } + } + if (showQueryOptions) { + MenuTextInput( + menuLabels = MENU_LABELS_SEARCH, + ) { label, selected -> + selectedSearchQuery[label] = selected + } + MenuDropDown( + menuOptions = MENU_OPTIONS_SEARCH, + ) { label, selected -> + selectedSearchQuery[label] = selected + } + } + + // form final deeplink url + val arguments = when (selectedPath.value) { + PATH_INCLUDE -> "/${selectedFilter}" + PATH_SEARCH -> { + buildString { + selectedSearchQuery.forEach { entry -> + if (entry.value.isNotEmpty()) { + val prefix = if (isEmpty()) "?" else "&" + append("$prefix${entry.key}=${entry.value}") + } + } + } + } + + else -> "" + } + val finalUrl = "${PATH_BASE}/${selectedPath.value}$arguments" + TextContent("Final url:\n$finalUrl") + // deeplink to target + PaddedButton("Deeplink Away!", onClick = dropUnlessResumed { + val intent = Intent( + this@CreateDeepLinkActivity, + MainActivity::class.java + ) + // start activity with the url + intent.data = finalUrl.toUri() + startActivity(intent) + }) + } + } + } +} + +private const val KEY_PATH = "path" +private val MENU_OPTIONS_PATH = mapOf( + KEY_PATH to listOf( + STRING_LITERAL_HOME, + PATH_INCLUDE, + PATH_SEARCH, + ), +) + +private val MENU_OPTIONS_FILTER = mapOf( + UsersKey.FILTER_KEY to listOf(UsersKey.FILTER_OPTION_RECENTLY_ADDED, UsersKey.FILTER_OPTION_ALL), +) + +private val MENU_OPTIONS_SEARCH = mapOf( + SearchKey::firstName.name to listOf( + EMPTY, + FIRST_NAME_JOHN, + FIRST_NAME_TOM, + FIRST_NAME_MARY, + FIRST_NAME_JULIE + ), + SearchKey::location.name to listOf(EMPTY, LOCATION_CA, LOCATION_BC, LOCATION_BR, LOCATION_US) +) + +private val MENU_LABELS_SEARCH = listOf(SearchKey::ageMin.name, SearchKey::ageMax.name) + +``` + +``` +package com.example.nav3recipes.deeplink.basic.util + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.AbstractDecoder +import kotlinx.serialization.encoding.CompositeDecoder +import kotlinx.serialization.modules.EmptySerializersModule +import kotlinx.serialization.modules.SerializersModule + +/** + * Decodes the list of arguments into a a back stack key + * + * **IMPORTANT** This decoder assumes that all argument types are Primitives. + */ +@OptIn(ExperimentalSerializationApi::class) +internal class KeyDecoder( + private val arguments: Map, +) : AbstractDecoder() { + + override val serializersModule: SerializersModule = EmptySerializersModule() + private var elementIndex: Int = -1 + private var elementName: String = "" + + /** + * Decodes the index of the next element to be decoded. Index represents a position of the + * current element in the [descriptor] that can be found with [descriptor].getElementIndex. + * + * The returned index will trigger deserializer to call [decodeValue] on the argument at that + * index. + * + * The decoder continually calls this method to process the next available argument until this + * method returns [CompositeDecoder.DECODE_DONE], which indicates that there are no more + * arguments to decode. + * + * This method should sequentially return the element index for every element that has its value + * available within [arguments]. + */ + override fun decodeElementIndex(descriptor: SerialDescriptor): Int { + var currentIndex = elementIndex + while (true) { + // proceed to next element + currentIndex++ + // if we have reached the end, let decoder know there are not more arguments to decode + if (currentIndex >= descriptor.elementsCount) return CompositeDecoder.DECODE_DONE + val currentName = descriptor.getElementName(currentIndex) + // Check if bundle has argument value. If so, we tell decoder to process + // currentIndex. Otherwise, we skip this index and proceed to next index. + if (arguments.contains(currentName)) { + elementIndex = currentIndex + elementName = currentName + return elementIndex + } + } + } + + /** + * Returns argument value from the [arguments] for the argument at the index returned by + * [decodeElementIndex] + */ + override fun decodeValue(): Any { + val arg = arguments[elementName] + checkNotNull(arg) { "Unexpected null value for non-nullable argument $elementName" } + return arg + } + + override fun decodeNull(): Nothing? = null + + // we want to know if it is not null, so its !isNull + override fun decodeNotNullMark(): Boolean = arguments[elementName] != null +} +``` + +``` +package com.example.nav3recipes.deeplink.basic.util + +import android.net.Uri + +/** + * Parse the requested Uri and store it in a easily readable format + * + * @param uri the target deeplink uri to link to + */ +internal class DeepLinkRequest( + val uri: Uri +) { + /** + * A list of path segments + */ + val pathSegments: List = uri.pathSegments + + /** + * A map of query name to query value + */ + val queries = buildMap { + uri.queryParameterNames.forEach { argName -> + this[argName] = uri.getQueryParameter(argName)!! + } + } + + // TODO add parsing for other Uri components, i.e. fragments, mimeType, action +} +``` + +```` +package com.example.nav3recipes.deeplink.basic.util + +import android.net.Uri +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.SerialKind +import kotlinx.serialization.encoding.CompositeDecoder +import java.io.Serializable + +/** + * Parse a supported deeplink and stores its metadata as a easily readable format + * + * The following notes applies specifically to this particular sample implementation: + * + * The supported deeplink is expected to be built from a serializable backstack key [T] that + * supports deeplink. This means that if this deeplink contains any arguments (path or query), + * the argument name must match any of [T] member field name. + * + * One [DeepLinkPattern] should be created for each supported deeplink. This means if [T] + * supports two deeplink patterns: + * ``` + * val deeplink1 = www.nav3recipes.com/home + * val deeplink2 = www.nav3recipes.com/profile/{userId} + * ``` + * Then two [DeepLinkPattern] should be created + * ``` + * val parsedDeeplink1 = DeepLinkPattern(T.serializer(), deeplink1) + * val parsedDeeplink2 = DeepLinkPattern(T.serializer(), deeplink2) + * ``` + * + * This implementation assumes a few things: + * 1. all path arguments are required/non-nullable - partial path matches will be considered a non-match + * 2. all query arguments are optional by way of nullable/has default value + * + * @param T the backstack key type that supports the deeplinking of [uriPattern] + * @param serializer the serializer of [T] + * @param uriPattern the supported deeplink's uri pattern, i.e. "abc.com/home/{pathArg}" + */ +internal class DeepLinkPattern( + val serializer: KSerializer, + val uriPattern: Uri +) { + /** + * Help differentiate if a path segment is an argument or a static value + */ + private val regexPatternFillIn = Regex("\\{(.+?)\\}") + + // TODO make these lazy + /** + * parse the path into a list of [PathSegment] + * + * order matters here - path segments need to match in value and order when matching + * requested deeplink to supported deeplink + */ + val pathSegments: List = buildList { + uriPattern.pathSegments.forEach { segment -> + // first, check if it is a path arg + var result = regexPatternFillIn.find(segment) + if (result != null) { + // if so, extract the path arg name (the string value within the curly braces) + val argName = result.groups[1]!!.value + // from [T], read the primitive type of this argument to get the correct type parser + val elementIndex = serializer.descriptor.getElementIndex(argName) + if (elementIndex == CompositeDecoder.UNKNOWN_NAME) { + throw IllegalArgumentException( + "Path parameter '{$argName}' defined in the DeepLink $uriPattern does not exist in the Serializable class '${serializer.descriptor.serialName}'." + ) + } + + val elementDescriptor = serializer.descriptor.getElementDescriptor(elementIndex) + // finally, add the arg name and its respective type parser to the map + add(PathSegment(argName, true, getTypeParser(elementDescriptor.kind))) + } else { + // if its not a path arg, then its just a static string path segment + add(PathSegment(segment, false, getTypeParser(PrimitiveKind.STRING))) + } + } + } + + /** + * Parse supported queries into a map of queryParameterNames to [TypeParser] + * + * This will be used later on to parse a provided query value into the correct KType + */ + val queryValueParsers: Map = buildMap { + uriPattern.queryParameterNames.forEach { paramName -> + val elementIndex = serializer.descriptor.getElementIndex(paramName) + // Ignore static query parameters that are not in the Serializable class + if (elementIndex != CompositeDecoder.UNKNOWN_NAME) { + val elementDescriptor = serializer.descriptor.getElementDescriptor(elementIndex) + this[paramName] = getTypeParser(elementDescriptor.kind) + } + } + } + + /** + * Metadata about a supported path segment + */ + class PathSegment( + val stringValue: String, + val isParamArg: Boolean, + val typeParser: TypeParser + ) +} + +/** + * Parses a String into a Serializable Primitive + */ +private typealias TypeParser = (String) -> Serializable + +private fun getTypeParser(kind: SerialKind): TypeParser { + return when (kind) { + PrimitiveKind.STRING -> Any::toString + PrimitiveKind.INT -> String::toInt + PrimitiveKind.BOOLEAN -> String::toBoolean + PrimitiveKind.BYTE -> String::toByte + PrimitiveKind.CHAR -> String::toCharArray + PrimitiveKind.DOUBLE -> String::toDouble + PrimitiveKind.FLOAT -> String::toFloat + PrimitiveKind.LONG -> String::toLong + PrimitiveKind.SHORT -> String::toShort + else -> throw IllegalArgumentException( + "Unsupported argument type of SerialKind:$kind. The argument type must be a Primitive." + ) + } +} +```` + +``` +package com.example.nav3recipes.deeplink.basic.util + +import android.util.Log +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.KSerializer + +internal class DeepLinkMatcher( + val request: DeepLinkRequest, + val deepLinkPattern: DeepLinkPattern +) { + /** + * Match a [DeepLinkRequest] to a [DeepLinkPattern]. + * + * Returns a [DeepLinkMatchResult] if this matches the pattern, returns null otherwise + */ + fun match(): DeepLinkMatchResult? { + if (request.uri.scheme != deepLinkPattern.uriPattern.scheme) return null + if (!request.uri.authority.equals(deepLinkPattern.uriPattern.authority, ignoreCase = true)) return null + if (request.pathSegments.size != deepLinkPattern.pathSegments.size) return null + // exact match (url does not contain any arguments) + if (request.uri == deepLinkPattern.uriPattern) + return DeepLinkMatchResult(deepLinkPattern.serializer, mapOf()) + + val args = mutableMapOf() + // match the path + request.pathSegments + .asSequence() + // zip to compare the two objects side by side, order matters here so we + // need to make sure the compared segments are at the same position within the url + .zip(deepLinkPattern.pathSegments.asSequence()) + .forEach { it -> + // retrieve the two path segments to compare + val requestedSegment = it.first + val candidateSegment = it.second + // if the potential match expects a path arg for this segment, try to parse the + // requested segment into the expected type + if (candidateSegment.isParamArg) { + val parsedValue = try { + candidateSegment.typeParser.invoke(requestedSegment) + } catch (e: IllegalArgumentException) { + Log.e(TAG_LOG_ERROR, "Failed to parse path value:[$requestedSegment].", e) + return null + } + args[candidateSegment.stringValue] = parsedValue + } else if(requestedSegment != candidateSegment.stringValue){ + // if it's path arg is not the expected type, its not a match + return null + } + } + // match queries (if any) + request.queries.forEach { query -> + val name = query.key + // If the pattern does not define this query parameter, ignore it. + // This prevents a NullPointerException. + val queryStringParser = deepLinkPattern.queryValueParsers[name]?: return@forEach + + val queryParsedValue = try { + queryStringParser.invoke(query.value) + } catch (e: IllegalArgumentException) { + Log.e(TAG_LOG_ERROR, "Failed to parse query name:[$name] value:[${query.value}].", e) + return null + } + args[name] = queryParsedValue + } + // provide the serializer of the matching key and map of arg names to parsed arg values + return DeepLinkMatchResult(deepLinkPattern.serializer, args) + } +} + + +/** + * Created when a requested deeplink matches with a supported deeplink + * + * @param [T] the backstack key associated with the deeplink that matched with the requested deeplink + * @param serializer serializer for [T] + * @param args The map of argument name to argument value. The value is expected to have already + * been parsed from the raw url string back into its proper KType as declared in [T]. + * Includes arguments for all parts of the uri - path, query, etc. + * */ +internal data class DeepLinkMatchResult( + val serializer: KSerializer, + val args: Map +) + +const val TAG_LOG_ERROR = "Nav3RecipesDeepLink" +``` + +``` +package com.example.nav3recipes.deeplink.basic.ui + +import com.example.nav3recipes.deeplink.basic.SearchKey + +/** + * String resources + */ +internal const val STRING_LITERAL_FILTER = "filter" +internal const val STRING_LITERAL_HOME = "home" +internal const val STRING_LITERAL_USERS = "users" +internal const val STRING_LITERAL_SEARCH = "search" +internal const val STRING_LITERAL_INCLUDE = "include" +internal const val PATH_BASE = "https://www.nav3recipes.com" +internal const val PATH_INCLUDE = "$STRING_LITERAL_USERS/$STRING_LITERAL_INCLUDE" +internal const val PATH_SEARCH = "$STRING_LITERAL_USERS/$STRING_LITERAL_SEARCH" +internal const val URL_HOME_EXACT = "$PATH_BASE/$STRING_LITERAL_HOME" + +internal const val URL_USERS_WITH_FILTER = "$PATH_BASE/$PATH_INCLUDE/{$STRING_LITERAL_FILTER}" +internal val URL_SEARCH = "$PATH_BASE/$PATH_SEARCH" + + "?${SearchKey::ageMin.name}={${SearchKey::ageMin.name}}" + + "&${SearchKey::ageMax.name}={${SearchKey::ageMax.name}}" + + "&${SearchKey::firstName.name}={${SearchKey::firstName.name}}" + + "&${SearchKey::location.name}={${SearchKey::location.name}}" +``` \ No newline at end of file diff --git a/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/modular-koin.md b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/modular-koin.md new file mode 100644 index 0000000..d1e7ad6 --- /dev/null +++ b/navigation/navigation-3/references/android/guide/navigation/navigation-3/recipes/modular-koin.md @@ -0,0 +1,287 @@ +# Modular Navigation Recipe (Koin) + +This recipe demonstrates how to structure a multi-module application using Navigation 3 and Koin for dependency injection. The goal is to create a decoupled architecture where navigation is defined and implemented in separate feature modules. It relies on the [`koin-compose-navigation3`](https://insert-koin.io/docs/reference/koin-compose/navigation3) artifact. + +## How it works + +The application is divided into several Android modules: + +- **`app` module** : This is the main application module. It `includes()` the feature modules and initializes a common `Navigator`. + +- **`common` module** : This module contains the core navigation logic used by both the application module and the feature modules. Namely, it defines a `Navigator` class that manages the back stack. + +- **Feature modules (e.g., `conversation`, `profile`)**: Each feature is split into two sub-modules: + + - **`api` module**: Defines the public API for the feature, including its navigation routes. This allows other modules to navigate to this feature without needing to know about its implementation details. + - **`impl` module** : Provides the implementation of the feature, including its composables and Koin `Module`. The Koin module uses the [`navigation`](https://insert-koin.io/docs/reference/koin-compose/navigation3/#declaring-navigation-entries) DSL to define the entry provider installers for the feature module. + +This modular approach allows for a clean separation of concerns, making the codebase more scalable and maintainable. Each feature is responsible for its own navigation logic, and the `app` module only combines these pieces together. +[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/modular/koin) + +``` +package com.example.nav3recipes.modular.koin + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.koin.androidx.scope.dsl.activityRetainedScope +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.dsl.module +import org.koin.dsl.navigation3.navigation + +// API +object Profile + +// IMPL +@OptIn(KoinExperimentalAPI::class) +val profileModule = module { + activityRetainedScope { + navigation { ProfileScreen() } + } +} + +@Composable +private fun ProfileScreen() { + val profileColor = MaterialTheme.colorScheme.surfaceVariant + Column( + modifier = Modifier + .fillMaxSize() + .background(profileColor) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Profile Screen", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } +} +``` + +``` +package com.example.nav3recipes.modular.koin + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Button +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.dropUnlessResumed +import com.example.nav3recipes.ui.theme.colors +import org.koin.androidx.scope.dsl.activityRetainedScope +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.dsl.module +import org.koin.dsl.navigation3.navigation + +// API +object ConversationList +data class ConversationDetail(val id: Int) { + val color: Color + get() = colors[id % colors.size] +} + +// IMPL +@OptIn(KoinExperimentalAPI::class) +val conversationModule = module { + activityRetainedScope { + navigation { + ConversationListScreen( + onConversationClicked = { conversationDetail -> + get().goTo(conversationDetail) + } + ) + } + + navigation { key -> + ConversationDetailScreen(key) { + get().goTo(Profile) + } + } + } +} + +@Composable +private fun ConversationListScreen( + onConversationClicked: (ConversationDetail) -> Unit +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + items(10) { index -> + val conversationId = index + 1 + val conversationDetail = ConversationDetail(conversationId) + val backgroundColor = conversationDetail.color + ListItem( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = dropUnlessResumed { + onConversationClicked(conversationDetail) + }), + headlineContent = { + Text( + text = "Conversation $conversationId", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface + ) + }, + colors = ListItemDefaults.colors( + containerColor = backgroundColor // Set container color directly + ) + ) + } + } +} + +@Composable +private fun ConversationDetailScreen( + conversationDetail: ConversationDetail, + onProfileClicked: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(conversationDetail.color) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Conversation Detail Screen: ${conversationDetail.id}", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = dropUnlessResumed(block = onProfileClicked)) { + Text("View Profile") + } + } +} +``` + +``` +package com.example.nav3recipes.modular.koin + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList + +class Navigator(startDestination: Any) { + val backStack : SnapshotStateList = mutableStateListOf(startDestination) + + fun goTo(destination: Any){ + backStack.add(destination) + } + + fun goBack(){ + backStack.removeLastOrNull() + } +} +``` + +``` +package com.example.nav3recipes.modular.koin + +import org.koin.androidx.scope.dsl.activityRetainedScope +import org.koin.dsl.module + +val appModule = module { + includes(profileModule,conversationModule) + + activityRetainedScope { + scoped { + Navigator(startDestination = ConversationList) + } + } +} +``` + +``` +package com.example.nav3recipes.modular.koin + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.ui.Modifier +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.ui.setEdgeToEdgeConfig +import org.koin.android.ext.android.inject +import org.koin.android.scope.AndroidScopeComponent +import org.koin.androidx.compose.navigation3.getEntryProvider +import org.koin.androidx.scope.activityRetainedScope +import org.koin.core.Koin +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.core.component.KoinComponent +import org.koin.core.scope.Scope +import org.koin.dsl.koinApplication + +/** + * This recipe demonstrates how to use a modular approach with Navigation 3, + * where different parts of the application are defined in separate modules and injected + * into the main app using Koin. + * + * Features (Conversation and Profile) are split into two modules: + * - api: defines the public facing routes for this feature + * - impl: defines the entryProviders for this feature, these are injected into the app's main activity + * The common module defines: + * - a common navigator class that exposes a back stack and methods to modify that back stack + * - a type that should be used by feature modules to inject entryProviders into the app's main activity + * The app module creates the navigator by supplying a start destination and provides this navigator + * to the rest of the app module (i.e. MainActivity) and the feature modules. + */ +@OptIn(KoinExperimentalAPI::class) +class KoinModularActivity : ComponentActivity(), AndroidScopeComponent, KoinComponent { + // Local Koin Context Instance + companion object { + private val localKoin = koinApplication { + modules(appModule) + }.koin + } + // Override default Koin context to use the local one + override fun getKoin(): Koin = localKoin + override val scope : Scope by activityRetainedScope() + val navigator: Navigator by inject() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setEdgeToEdgeConfig() + setContent { + Scaffold { paddingValues -> + NavDisplay( + backStack = navigator.backStack, + modifier = Modifier.padding(paddingValues), + onBack = { navigator.goBack() }, + entryProvider = getEntryProvider() + ) + } + } + } + +} +``` \ No newline at end of file diff --git a/play/engage-sdk-integration/SKILL.md b/play/engage-sdk-integration/SKILL.md index 2c9a9d7..afdb402 100644 --- a/play/engage-sdk-integration/SKILL.md +++ b/play/engage-sdk-integration/SKILL.md @@ -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. diff --git a/play/engage-sdk-integration/references/android/guide/playcore/engage/tv/getting-started.md b/play/engage-sdk-integration/references/android/guide/playcore/engage/tv/getting-started.md index 2afe8d9..9218a8a 100644 --- a/play/engage-sdk-integration/references/android/guide/playcore/engage/tv/getting-started.md +++ b/play/engage-sdk-integration/references/android/guide/playcore/engage/tv/getting-started.md @@ -1,5 +1,8 @@
+[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 diff --git a/play/engage-sdk-integration/references/patterns.md b/play/engage-sdk-integration/references/patterns.md index bc7f214..be290a9 100644 --- a/play/engage-sdk-integration/references/patterns.md +++ b/play/engage-sdk-integration/references/patterns.md @@ -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 = + client.publishContinuationCluster( + clusterRequestFactory.constructContinuationClusterRequest() + ) + return publishAndProvideResult(publishTask) + } + private suspend fun publishUserAccountManagement(): Result { val publishTask: Task if (isAccountSignedIn()) { @@ -181,6 +196,11 @@ class EngageWorker(context: Context, workerParams: WorkerParameters) : Coroutine // ... } + private fun getContinuationData(): List { + // Implement your app's data loading logic here. + // ... + } + private suspend fun publishAndProvideResult( publishTask: Task ): 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" diff --git a/play/engage-sdk-integration/references/requests.md b/play/engage-sdk-integration/references/requests.md new file mode 100644 index 0000000..c65f0a6 --- /dev/null +++ b/play/engage-sdk-integration/references/requests.md @@ -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", + "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", + "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", + "requirement": "Optional", + "adder": "addShoppingList(ShoppingList)", + "getter": "getShoppingLists()", + "adderAll": "addShoppingLists(List)" + } + } + }, + "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", + "requirement": "Optional", + "adder": "addFoodShoppingCart(FoodShoppingCart)", + "getter": "getFoodShoppingCarts()", + "adderAll": "addFoodShoppingCarts(List)" + } + } + }, + "PublishFoodShoppingListsRequest": { + "package": "com.google.android.engage.food.service.PublishFoodShoppingListsRequest", + "fields": { + "foodShoppingLists": { + "type": "List", + "requirement": "Optional", + "adder": "addFoodShoppingList(FoodShoppingList)", + "getter": "getFoodShoppingLists()", + "adderAll": "addFoodShoppingLists(List)" + } + } + }, + "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()" + } + } + } + } \ No newline at end of file diff --git a/play/play-billing-library-version-upgrade/SKILL.md b/play/play-billing-library-version-upgrade/SKILL.md index 83392ea..6784606 100644 --- a/play/play-billing-library-version-upgrade/SKILL.md +++ b/play/play-billing-library-version-upgrade/SKILL.md @@ -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 diff --git a/security/android-intent-security/SKILL.md b/security/android-intent-security/SKILL.md index ac09e4a..a94df09 100644 --- a/security/android-intent-security/SKILL.md +++ b/security/android-intent-security/SKILL.md @@ -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. diff --git a/testing/testing-setup/SKILL.md b/testing/testing-setup/SKILL.md index d92e36f..31207c7 100644 --- a/testing/testing-setup/SKILL.md +++ b/testing/testing-setup/SKILL.md @@ -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 diff --git a/testing/testing-setup/references/android/develop/ui/compose/testing/common-patterns.md b/testing/testing-setup/references/android/develop/ui/compose/testing/common-patterns.md index 3ae3f34..1bea76b 100644 --- a/testing/testing-setup/references/android/develop/ui/compose/testing/common-patterns.md +++ b/testing/testing-setup/references/android/develop/ui/compose/testing/common-patterns.md @@ -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 { diff --git a/wear/wear-compose-m3/SKILL.md b/wear/wear-compose-m3/SKILL.md index 861d2c4..2cb5b96 100644 --- a/wear/wear-compose-m3/SKILL.md +++ b/wear/wear-compose-m3/SKILL.md @@ -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 diff --git a/wear/wear-compose-m3/references/android/training/wearables/compose/migrate-to-material3.md b/wear/wear-compose-m3/references/android/training/wearables/compose/migrate-to-material3.md index 1969a7d..70af9c6 100644 --- a/wear/wear-compose-m3/references/android/training/wearables/compose/migrate-to-material3.md +++ b/wear/wear-compose-m3/references/android/training/wearables/compose/migrate-to-material3.md @@ -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. | diff --git a/xr/display-glasses-with-jetpack-compose-glimmer/SKILL.md b/xr/display-glasses-with-jetpack-compose-glimmer/SKILL.md index 449394c..63ce459 100644 --- a/xr/display-glasses-with-jetpack-compose-glimmer/SKILL.md +++ b/xr/display-glasses-with-jetpack-compose-glimmer/SKILL.md @@ -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 diff --git a/xr/display-glasses-with-jetpack-compose-glimmer/references/button-samples-source.md b/xr/display-glasses-with-jetpack-compose-glimmer/references/button-samples-source.md new file mode 100644 index 0000000..fba367d --- /dev/null +++ b/xr/display-glasses-with-jetpack-compose-glimmer/references/button-samples-source.md @@ -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() } +} +``` + +
\ No newline at end of file diff --git a/xr/display-glasses-with-jetpack-compose-glimmer/references/card-samples-source.md b/xr/display-glasses-with-jetpack-compose-glimmer/references/card-samples-source.md index 1586776..676f68a 100644 --- a/xr/display-glasses-with-jetpack-compose-glimmer/references/card-samples-source.md +++ b/xr/display-glasses-with-jetpack-compose-glimmer/references/card-samples-source.md @@ -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 diff --git a/xr/display-glasses-with-jetpack-compose-glimmer/references/card-source.md b/xr/display-glasses-with-jetpack-compose-glimmer/references/card-source.md index c260bfe..9c290c9 100644 --- a/xr/display-glasses-with-jetpack-compose-glimmer/references/card-source.md +++ b/xr/display-glasses-with-jetpack-compose-glimmer/references/card-source.md @@ -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, diff --git a/xr/display-glasses-with-jetpack-compose-glimmer/references/liststate-source.md b/xr/display-glasses-with-jetpack-compose-glimmer/references/liststate-source.md new file mode 100644 index 0000000..134df7c --- /dev/null +++ b/xr/display-glasses-with-jetpack-compose-glimmer/references/liststate-source.md @@ -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 = + 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 = 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(), + ) +``` + +
\ No newline at end of file