diff --git a/.agents/skills-sources.lock.json b/.agents/skills-sources.lock.json index 681a121..d380919 100644 --- a/.agents/skills-sources.lock.json +++ b/.agents/skills-sources.lock.json @@ -5,7 +5,7 @@ "name": "adaptive", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "jetpack-compose/adaptive", "license": "Apache-2.0" }, @@ -13,7 +13,7 @@ "name": "agp-9-upgrade", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "build-system/agp/agp-9-upgrade", "license": "Apache-2.0" }, @@ -37,7 +37,7 @@ "name": "android-cli", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "devtools/android-cli", "license": "Apache-2.0" }, @@ -69,7 +69,7 @@ "name": "android-for-cars-parked-apps", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "cars/android-for-cars-parked-apps", "license": "Apache-2.0" }, @@ -85,7 +85,7 @@ "name": "android-intent-security", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "security/android-intent-security", "license": "Apache-2.0" }, @@ -93,7 +93,7 @@ "name": "android-permissions-security", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "security/android-permissions-security", "license": "Apache-2.0" }, @@ -101,7 +101,7 @@ "name": "android-profiler", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "profilers/android-profiler", "license": "Apache-2.0" }, @@ -125,7 +125,7 @@ "name": "anr-audit-and-fix", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "performance/anr-audit-and-fix", "license": "Apache-2.0" }, @@ -133,7 +133,7 @@ "name": "appfunctions", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "device-ai/appfunctions", "license": "Apache-2.0" }, @@ -141,7 +141,7 @@ "name": "camerax", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "camera/camerax", "license": "Apache-2.0" }, @@ -165,7 +165,7 @@ "name": "display-glasses-with-jetpack-compose-glimmer", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "xr/display-glasses-with-jetpack-compose-glimmer", "license": "Apache-2.0" }, @@ -173,7 +173,7 @@ "name": "edge-to-edge", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "system/edge-to-edge", "license": "Apache-2.0" }, @@ -181,7 +181,7 @@ "name": "engage-sdk-integration", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "play/engage-sdk-integration", "license": "Apache-2.0" }, @@ -189,7 +189,7 @@ "name": "glance-canonical-layouts", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "system/glance-canonical-layouts", "license": "Apache-2.0" }, @@ -205,7 +205,7 @@ "name": "jetpack-compose-performance", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "jetpack-compose/jetpack-compose-performance", "license": "Apache-2.0" }, @@ -221,7 +221,7 @@ "name": "leanback-to-compose-tv-migration", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "tv/leanback-to-compose-tv-migration", "license": "Apache-2.0" }, @@ -229,7 +229,7 @@ "name": "litert-lm", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "device-ai/litert/litert-lm", "license": "Apache-2.0" }, @@ -237,7 +237,7 @@ "name": "litert-runtime", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "device-ai/litert/litert-runtime", "license": "Apache-2.0" }, @@ -245,7 +245,7 @@ "name": "media3-cast-integration", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "media/media3-cast-integration", "license": "Apache-2.0" }, @@ -253,7 +253,7 @@ "name": "migrate-xml-views-to-jetpack-compose", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "jetpack-compose/migration/migrate-xml-views-to-jetpack-compose", "license": "Apache-2.0" }, @@ -261,7 +261,7 @@ "name": "ml-kit-genai-prompt-api", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "device-ai/ml-kit-genai-prompt-api", "license": "Apache-2.0" }, @@ -269,7 +269,7 @@ "name": "navigation-3", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "navigation/navigation-3", "license": "Apache-2.0" }, @@ -277,7 +277,7 @@ "name": "navigation-event", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "navigation/navigation-event", "license": "Apache-2.0" }, @@ -285,7 +285,7 @@ "name": "play-billing-library-version-upgrade", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "play/play-billing-library-version-upgrade", "license": "Apache-2.0" }, @@ -293,7 +293,7 @@ "name": "play-policy-insights", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "play/play-policy-insights", "license": "Apache-2.0" }, @@ -301,7 +301,7 @@ "name": "projected-hardware", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "xr/projected-hardware", "license": "Apache-2.0" }, @@ -309,7 +309,7 @@ "name": "r8-analyzer", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "performance/r8-analyzer", "license": "Apache-2.0" }, @@ -317,7 +317,7 @@ "name": "restore-credentials", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "identity/restore-credentials", "license": "Apache-2.0" }, @@ -333,7 +333,7 @@ "name": "styles", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "jetpack-compose/theming/styles", "license": "Apache-2.0" }, @@ -341,7 +341,7 @@ "name": "testing-setup", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "testing/testing-setup", "license": "Apache-2.0" }, @@ -349,7 +349,7 @@ "name": "verified-email", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "identity/verified-email", "license": "Apache-2.0" }, @@ -357,7 +357,7 @@ "name": "wear-compose-m3", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "wear/wear-compose-m3", "license": "Apache-2.0" }, @@ -365,7 +365,7 @@ "name": "wear-widgets", "source": "android", "repository": "https://github.com/android/skills.git", - "revision": "95ea931a1ad8f9b2be132ae0cfb2aad964d71118", + "revision": "4ebabf2b0fd6c04b7150023c8b612f80592a6226", "path": "wear/wear-widgets", "license": "Apache-2.0" } diff --git a/.agents/skills/adaptive/SKILL.md b/.agents/skills/adaptive/SKILL.md index d878f36..d17a1ae 100644 --- a/.agents/skills/adaptive/SKILL.md +++ b/.agents/skills/adaptive/SKILL.md @@ -9,22 +9,24 @@ description: Instructions to make or update an app's UI so that it adapts to dif or resizing a window). It also covers multi-pane layouts using Navigation3 Scenes, adaptive UI components (such as buttons) with varying target sizes, and adaptive layouts (including navigation areas - nav rails and nav bars) using the Compose - Grid and FlexBox APIs. + Grid and FlexBox APIs. It also covers desktop experiences with keyboard shortcuts, + tooltips and custom cursors. license: Complete terms in LICENSE.txt metadata: author: Google LLC - last-updated: '2026-10-16' + last-updated: '2026-10-22' keywords: - - Jetpack Compose - - Adaptive UI - - Navigation - - Multi-pane - - Layouts - - Responsive Design + - android + - ui + - adaptive - Grid - FlexBox - MediaQuery - - Android Development + - navigation + - foldables + - state + - resizability + - configuration-changes --- ## Prerequisites @@ -45,6 +47,7 @@ task. - Step 4: Make vertical lists adaptive by changing the number of columns - Step 5: Hide app bars when scrolling - Step 6: Preserve UI state across configuration changes +- Step 7: Add desktop experiences ## Step 1. Verify current UI @@ -414,6 +417,26 @@ private fun rememberExoPlayer(@RawRes videoResId: Int, fromUri: Unit.(Uri) -> Me
+## Step 7: Add desktop experiences + +### Step 7.1: Identify the required desktop feature + +Look at the user's prompt, requirements, or uploaded files to determine which +desktop feature needs to be implemented. + +### Step 7.2: Run feature-specific processing + +Based on the feature identified, you must read the corresponding reference file +and follow its instructions: + +- **Keyboard Shortcuts** -\> read [`references/keyboard_shortcuts.md`](references/keyboard_shortcuts.md) +- **Tooltips** -\> read [`references/tooltips.md`](references/tooltips.md) +- **Custom Cursors** -\> read [`references/custom_cursors.md`](references/custom_cursors.md) + +Each reference starts by searching the whole app for the targets of that +feature. Write that list down first, implement all of it (not only the first +screen you open), and re-check the list before you finish. + ## Final step: Build and test Build the app and run the local tests. If the project has screenshot tests, run diff --git a/.agents/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/get-started.md b/.agents/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/get-started.md index 44d6d13..324c7f1 100644 --- a/.agents/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/get-started.md +++ b/.agents/skills/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.13.0-beta01" + compose = "1.13.0-beta02" [libraries] androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "compose" } diff --git a/.agents/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/get-started.md b/.agents/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/get-started.md index d3e010d..bd556fa 100644 --- a/.agents/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/get-started.md +++ b/.agents/skills/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.13.0-beta01" + compose = "1.13.0-beta02" [libraries] androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "compose" } diff --git a/.agents/skills/adaptive/references/custom_cursors.md b/.agents/skills/adaptive/references/custom_cursors.md new file mode 100644 index 0000000..26bc037 --- /dev/null +++ b/.agents/skills/adaptive/references/custom_cursors.md @@ -0,0 +1,67 @@ +## Description + +Use this document when adding or modifying pointer cursor behavior in a Jetpack +Compose application targeting Android large screens and desktop environments. + +## Workflow + +For each following step, search the whole codebase for the listed APIs, write +down every match, and only then implement with +`Modifier.pointerHoverIcon(icon)` on the element itself. Pick the cursor type +from these steps yourself. Before finishing, re-run the searches: +every match is either handled or skipped for a stated reason. + +### Step 1: Find and process draggable elements (Grab / Grabbing) + +Go through the app, find all the draggable elements that would benefit from +adding a `TYPE_GRAB` / `TYPE_GRABBING` cursor. + +- **Search for:** `dragAndDropSource`, `detectDragGesturesAfterLongPress`, reorderable list items, `draggable(` on movable content. +- **Examples:** Reorderable list items, movable floating windows, map surfaces, draggable sliders, and custom drag-and-drop targets. +- **Implementation:** For each one of them, implement the cursor using `PointerIcon(android.view.PointerIcon.TYPE_GRAB)` for movable surfaces and `PointerIcon(android.view.PointerIcon.TYPE_GRABBING)` only during an active drag. + +### Step 2: Find and process handwriting and drawing elements (Crosshair) + +Go through the app, find all handwriting or precision drawing elements. + +- **Search for:** `Canvas(`, `InProgressStrokesView`, drawing surfaces in `AndroidView`, `pointerInput` blocks that draw strokes or shapes. +- **Examples:** Canvas drawing areas, signature capture pads, image cropping tools and spatial selection bounding boxes. +- **Implementation:** For each one of them, implement the cursor using `PointerIcon.Crosshair`. + +### Step 3: Find and process resizable elements (Resize) + +Go through the app, find all splitters, resizable edges, and resize handles. + +- **Search for:** `VerticalDragHandle`, `paneExpansionDragHandle`, `paneExpansionDraggable`, `dragHandle =` of sheets, `anchoredDraggable`, `draggable(` on a divider. +- **Examples:** Split pane dividers (horizontal or vertical), resizable dialog edges, and bottom sheet drag handles. +- **Implementation:** For each one, implement the correct system cursor (e.g., `PointerIcon(android.view.PointerIcon.TYPE_HORIZONTAL_DOUBLE_ARROW)` for vertical splitters). Match the cursor direction to the handle. + +### Step 4: Find and process clickable elements (Hand) + +Go through the app, find all links, clickable text, and icon-only controls. + +- **Search for:** `IconButton`, `IconToggleButton`, `FloatingActionButton`, `NavigationBarItem`, `NavigationRailItem`, `NavigationDrawerItem`, `navigationSuiteItems`, `Tab(`, `clickable` on an `Icon` or `Text`. +- **Examples:** Hyperlinks in text, standalone icon buttons, navigation bar, rail and drawer items, tabs, and active toggle switches. +- **Implementation:** For each one, implement `PointerIcon.Hand` when the element is active. When inactive or disabled, you must default back to `PointerIcon.Default`. Do not use `Hand` for every single clickable surface. + +### Step 5: Find and process text selection elements (Text) + +Go through the app, find all custom editable or selectable text regions. + +- **Examples:** Custom code editors, selectable log outputs, and custom text selection overlays. +- **Implementation:** For each one, implement `PointerIcon.Text`. Ensure you do not override the correct default cursor of standard Compose text fields. + +### Step 6: Find and process temporarily blocked elements (Wait) + +Go through the app, find short, temporarily blocked regions. + +- **Examples:** Form submission overlays, local processing states, and refreshing panels. +- **Implementation:** For each one, implement the cursor using `PointerIcon(android.view.PointerIcon.TYPE_WAIT)`. + +## Reference links + +Consult these references before proceeding with the implementation. + +- [Compose `pointerHoverIcon` API reference](https://developer.android.com/reference/kotlin/androidx/compose/ui/input/pointer/package-summary#pointerHoverIcon(androidx.compose.ui.Modifier,androidx.compose.ui.input.pointer.PointerIcon,kotlin.Boolean)) +- [Compose `PointerIcon` API reference](https://developer.android.com/reference/kotlin/androidx/compose/ui/input/pointer/PointerIcon) +- [Android pointer input overview](https://developer.android.com/develop/ui/compose/touch-input/pointer-input) \ No newline at end of file diff --git a/.agents/skills/adaptive/references/keyboard_shortcuts.md b/.agents/skills/adaptive/references/keyboard_shortcuts.md new file mode 100644 index 0000000..02e28c2 --- /dev/null +++ b/.agents/skills/adaptive/references/keyboard_shortcuts.md @@ -0,0 +1,97 @@ +## Description + +Use this document when you need to add, redesign, or expose keyboard +shortcuts in a Jetpack Compose application. + +## Workflow + +### Step 1: Inventory the actions + +Before writing any handler, list the actions a keyboard user would want and +the screen or container that owns each one: + +1. Top-level navigation destinations (bottom bar, rail, drawer, tabs), in display order. +2. The primary actions of each screen. Count actions, not buttons: a "New" button that opens a menu with two options is two actions. +3. Existing keyboard handling to extend rather than duplicate: `onKeyEvent` / `onPreviewKeyEvent`, `KeyboardActions`, `onProvideKeyboardShortcuts`, and the text fields that hold focus while the user types. + +Map each entry to a shortcut with the table in Step 2 and keep the list. + +### Step 2: Choose the shortcuts + +- Prefer common OS conventions and use Ctrl as the primary modifier on Android. +- Add domain shortcuts only for frequent, central actions. +- Do not override standard text editing shortcuts. +- Use Escape to cancel or dismiss the current temporary UI. Do not use it as general Back navigation. + +You must implement these keyboard shortcuts when they are relevant to +the app. The category tells you which app types each group applies to: + +| Category | Action | Shortcut | Notes | +|---|---|---|---| +| General | Save | Ctrl+S | Use when the app has an explicit save action. | +| General | Find/Search | Ctrl+F | Focus the search field. | +| General | New item | Ctrl+N | Primary creation action (note, message, document, ...). Use Ctrl+Shift+N for a secondary creation action. | +| General | Open | Ctrl+O | Use for open/import flows. | +| General | Refresh | Ctrl+R or F5 | Avoid browser reload if web content is focused. | +| General | Dismiss temporary UI | Escape | Close the current dialog, menu, sheet or search mode. | +| Editing | Undo / Redo | Ctrl+Z / Ctrl+Shift+Z | Documents, canvases and lists; text fields already handle it. | +| Editing | Cut / Copy / Paste | Ctrl+X / Ctrl+C / Ctrl+V | Text fields already handle it; implement for custom selections (list items, canvas objects). | +| Editing | Select all | Ctrl+A | Lists and canvases; never override it in text fields. | +| Editing | Delete selected item | Delete or Backspace | Never trigger during text edit. | +| Editing | Zoom in / out / reset | Ctrl+Plus / Ctrl+Minus / Ctrl+0 | Documents, images, maps and canvases. | +| Navigation | Navigation destinations | Ctrl+1 to Ctrl+9 | Switch between primary navigation items (e.g., Tabs, Navigation Rail, or Drawer items) in order. | +| Navigation | Next / previous tab or document | Ctrl+Tab / Ctrl+Shift+Tab | Cycle through the open items of the current screen. | +| Communication | Send a message or submit a form | Enter | Chat-style input. Use Shift+Enter for a new line in multi-line text fields. | +| Communication | Send long-form content | Ctrl+Enter | Email, posts, comments: when Enter must insert a new line. | +| Communication | Switch active chat | Ctrl+Tab / Ctrl+Shift+Tab | Next / previous conversation in the list. | +| Media | Play / pause | Space | Only when the player or media list has focus. | +| Media | Seek backward / forward | Left / Right arrow | 5-10s step; Shift+arrow for a 30s-1m step. | +| Media | Next / previous track | Ctrl+Right / Ctrl+Left | Playlists and queues. | +| Media | Volume up / down | Up / Down arrow | Only when the player has focus. | +| Media | Mute / unmute | M (or Ctrl+M) | Use Ctrl+M when M would type into a focused field. | +| Media | Fullscreen | F | Escape exits fullscreen. | + +### Step 3: Decide shortcut scope + +Attach your shortcut dispatcher to the correct level of the UI hierarchy: + +- **App-wide commands:** Attach the handler to the root navigation container or main scaffold (e.g., for switching root navigation destinations). +- **Screen-wide commands:** Attach the handler to a common ancestor of the focused screen content. +- **Modal commands:** Attach the handler inside the active dialog, menu, or sheet. +- **Contextual commands:** Attach the handler to the focused editor, canvas, list, or media surface. + +### Step 4: Implement shortcut handling + +Handle keyboard shortcuts in the relevant composable with +`onPreviewKeyEvent`. Prefer it over `onKeyEvent` for shortcuts that must work +while a text field has focus (Enter or Ctrl+Enter to send, Ctrl+S in an editor): +the field consumes those keys first, so an `onKeyEvent` handler never sees them. + +- **Filter for KeyDown:** Check `event.type == KeyEventType.KeyDown` and ignore repeats (`event.nativeKeyEvent.repeatCount > 0`); otherwise the action also runs on key release and while the key is held. +- **Manage focus:** Key events are only dispatched to the active focus path. Ensure your target container or component uses `Modifier.focusable()` and actively holds focus (e.g., using `FocusRequester.requestFocus()`) to listen for these events. + +### Step 5: Publish to Keyboard Shortcut Helper + +Every shortcut on the Step 1 list must be visible to users using the system +Keyboard Shortcut Helper, with the action's visible name as the label (e.g. +"New drawing note"). Before finishing, compare the Helper entries with the +handlers you wrote: same keys, same modifiers, nothing missing. + +Follow the Keyboard Shortcut Helper [guide](https://developer.android.com/develop/ui/compose/touch-input/keyboard-input/keyboard-shortcuts-helper). + +## Common pitfalls + +- Adding shortcuts for rare actions. +- Attaching handlers at the wrong scope or outside the active focus path. +- Stealing focus with an invisible root container. +- Intercepting focused-child behavior unintentionally with `onPreviewKeyEvent`. +- Repeating one-time commands on repeated `KeyDown` events. +- Consuming disabled or unhandled commands. +- Publishing Keyboard Shortcut Helper entries that do not match actual dispatch. + +## Reference links + +Consult these references before proceeding with the implementation. + +- Compose keyboard input [guide](https://developer.android.com/develop/ui/compose/touch-input/keyboard-input/commands) +- Android desktop keyboard interaction [guidance](https://developer.android.com/design/ui/desktop/guides/interaction/keyboard) \ No newline at end of file diff --git a/.agents/skills/adaptive/references/tooltips.md b/.agents/skills/adaptive/references/tooltips.md new file mode 100644 index 0000000..d40186d --- /dev/null +++ b/.agents/skills/adaptive/references/tooltips.md @@ -0,0 +1,71 @@ +## Description + +Use this document when you need to add or redesign tooltips in a Jetpack Compose +application targeting Android large screens and desktop environments. Do not use +Compose Multiplatform Desktop TooltipArea. + +Expected output: + +- Brief analysis of targets and input methods, followed by an implementation that preserves accessibility, focus, and design-system rules. + +## Workflow + +### Step 1: Identify tooltip targets and text + +Search the code and write down every candidate before implementing. Decide +targets and wording yourself. + +1. Icon-only controls: `IconButton`, `IconToggleButton`, `FloatingActionButton`, `TopAppBar` `navigationIcon` and `actions`, toolbar and toolbox icons, a `clickable` `Icon` with a `contentDescription`, and navigation items (`NavigationBarItem`, `NavigationRailItem`, `NavigationDrawerItem`, `navigationSuiteItems`, `Tab`) whose label is hidden, like `alwaysShowLabel = false` or a collapsed rail. +2. Unclear toolbar actions and controls with hidden keyboard shortcuts. +3. Skip controls that are already clear from visible text. Never use a tooltip as the only source of a label, instruction, validation error, or disabled-state requirement. +4. Check whether long-press already starts dragging, selection, or a context menu. Skip the tooltip if it conflicts. + +For each tooltip, use a localized string and brief, sentence-case text: + +- Start from the control's accessibility label: for an icon-only control the `Icon`'s `contentDescription` is normally the right tooltip text. Reuse the same string resource for both so they cannot drift, and keep the `contentDescription` on the icon --- a tooltip never replaces it. +- Prefer verbs for actions: `Add column`, `Export CSV`, `Mute track`. +- Keep plain tooltips short and clear. +- Do not repeat visible button text unless the tooltip adds a shortcut or consequence. +- Add shortcut hints only when accurate, e.g., `Save (Ctrl+S)`. +- For disabled controls, explain the requirement: `Select a row first`. + +### Step 2: Implement the tooltips + +Iterate through the identified targets and implement tooltips for each according +to the following patterns: + +- **Reuse existing components:** Use a shared wrapper for repeated tooltips and inline `TooltipBox` for one-off tooltips. +- **Tooltip type:** Use `PlainTooltip` for a short label. Use `RichTooltip` for extra details or one action. +- **Tooltip trigger:** Use the default hover-effect, long-press, and + keyboard-focus behavior unless the tooltip should only be shown from code. + +- **API:** `TooltipBox(positionProvider, tooltip = { PlainTooltip { Text(...) } + }, state = rememberTooltipState()) { control }`. Check the Material 3 + version in the Gradle files first: on 1.4.0 or newer use + `TooltipDefaults.rememberTooltipPositionProvider(positioning = + TooltipAnchorPosition.Above)`; on 1.3.x that function does not exist, use + `TooltipDefaults.rememberPlainTooltipPositionProvider()`. + +Follow these rules: + +- Give each `TooltipBox` its own `TooltipState`. +- Wrap exactly one control. Keep its click, focus, enabled state, and accessibility on the control, and keep the wrapper bounds tight. +- For a rich tooltip with an action, use `rememberTooltipState(isPersistent = + true)`, set `hasAction = true`, and dismiss it after the action. +- Before finishing, re-run the searches from Step 1: every match now has a tooltip or a stated reason to skip it. Do not stop at the first screen. + +#### Show tooltips from code (optional) + +Use this only for brief, nonessential help, such as feature education. + +- Call `tooltipState.show()` from a coroutine. +- Call `tooltipState.dismiss()` directly when the tooltip is no longer needed. +- Set `enableUserInput = false` if hover-effect, long-press, and keyboard focus should not show it. +- Trigger `show()` from a user event or a one-time effect, not directly during composition. + +## Reference links + +Consult these references before proceeding with the implementation. + +- AndroidX Material 3 tooltip [samples](https://android.googlesource.com/platform/frameworks/support/+/androidx-main/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/TooltipSamples.kt) +- Material Design 3 tooltip [guidelines](https://m3.material.io/components/tooltips/guidelines) \ No newline at end of file diff --git a/.agents/skills/android-emulator-skill/scripts/emu_health_check.sh b/.agents/skills/android-emulator-skill/scripts/emu_health_check.sh old mode 100644 new mode 100755 diff --git a/.agents/skills/android-intent-security/SKILL.md b/.agents/skills/android-intent-security/SKILL.md index e028309..65ce1ab 100644 --- a/.agents/skills/android-intent-security/SKILL.md +++ b/.agents/skills/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-09-25' + last-updated: '2026-10-22' keywords: - recipe - Android @@ -89,8 +89,8 @@ IF (AndroidX Core 1.9.0+ and higher is available) { MUST construct an `IntentSanitizer` to explicitly allowlist components, actions, data, and extras. MUST call `sanitizeByThrowing()` or `sanitizeByFiltering()` before launching. } ELSE { -MUST verify that the nested Intent's target package matches the current application package. -MUST verify that the target component of the nested Intent is publicly exported. +MUST reject or strip URI permission grant flags (`FLAG_GRANT_READ_URI_PERMISSION`, `FLAG_GRANT_WRITE_URI_PERMISSION`, `FLAG_GRANT_PERSISTABLE_URI_PERMISSION`, `FLAG_GRANT_PREFIX_URI_PERMISSION`). +MUST resolve the target component using `resolveActivity()` and verify that both its `packageName` and `className` match an explicitly allowlisted component. } NEVER launch the nested Intent directly without validation. } ELSE IF (the component handles broadcasts) { @@ -123,8 +123,8 @@ MUST set `android:grantUriPermissions="false"` unless temporary URL access is st ### 6. Service caller verification logic IF (an exported service communicates with trusted sister/partner apps) { -MUST retrieve the calling UID using `Binder.getCallingUid()` and resolve it to package names using `PackageManager.getPackagesForUid()`. -MUST verify that the calling package signature fingerprint matches your trusted certificate hash. +MUST expose a cross-process interface (such as an AIDL `Stub` or `Messenger`) rather than a local `Binder` subclass, which only works within the same process. +MUST verify the caller on every IPC request---using `Binder.getCallingUid()` inside an AIDL `Stub` method (or `onTransact()`) or `Message.sendingUid` in a `Messenger` `Handler`---and verify that the calling package signature fingerprint matches your trusted certificate hash. } *** ** * ** *** @@ -159,26 +159,21 @@ fun safeIntentRedirectionManual() { throw SecurityException("Nested intent contains forbidden URI permission grant flags!") } - val pm = packageManager - val target = nestedIntent.resolveActivity(pm) - if (target != null) { - // 2. Verify target is within the same package - if (target.packageName != packageName) { - throw SecurityException("Cross-app intent redirection is forbidden!") - } - try { - // 3. Verify target activity is exported - val info = pm.getActivityInfo(target, 0) - if (!info.exported) { - throw SecurityException("Target activity is private: ${target.className}") - } - // 4. Explicitly set the component to prevent intent interception - nestedIntent.component = target - // Safe to launch - startActivity(nestedIntent) - } catch (e: PackageManager.NameNotFoundException) { - Log.e("Security", "Failed to resolve target activity", e) - } + // 2. Clear any selector intent that could override resolution + nestedIntent.selector = null + + // 3. Resolve the target activity and verify both packageName and className + // match the expected allowlisted destination + val target = nestedIntent.resolveActivity(packageManager) + if (target != null && + target.packageName == packageName && + target.className == TargetActivity::class.java.name + ) { + // 4. Explicitly pin the resolved component to prevent intent interception + nestedIntent.component = target + startActivity(nestedIntent) + } else { + throw SecurityException("Invalid or disallowed redirect target: $target") } } } @@ -199,30 +194,34 @@ Filter or reject dynamic intents using AndroidX `IntentSanitizer` (AndroidX Core ```kotlin fun safeIntentRedirectionSanitizer() { - val untrustedIntent = IntentCompat.getParcelableExtra(intent, "EXTRA_NESTED_INTENT", Intent::class.java) - if (untrustedIntent != null) { - // Define the strict boundaries for allowed redirection target - val sanitizer = IntentSanitizer.Builder() - .allowComponent(ComponentName("com.example.app", "com.example.app.SafeTargetActivity")) // Explicitly allowed target - .allowAction(Intent.ACTION_VIEW) // Explicitly allowed actions - .allowDataWithAuthority("com.example.app.provider") // Allowed URI authority - .allowType("text/plain") // Allowed mime type - .allowExtra("user_display_name", String::class.java) // Safe type-enforced extras - // Note: URI permission flags are NOT allowed, so the sanitizer will automatically strip or throw on them - .build() - - try { - // Option A: Throws SecurityException if the intent violates policies - val safeIntent = sanitizer.sanitizeByThrowing(untrustedIntent) - startActivity(safeIntent) - } catch (e: SecurityException) { - Log.e("SECURITY_ALERT", "Attempted launch of non-allowlisted intent blocked", e) - } + // Allowlist: anything NOT listed is rejected (only component is required; other fields are optional). + // No allowFlags(): every flag, including FLAG_GRANT_*_URI_PERMISSION, is rejected. + val sanitizer = IntentSanitizer.Builder() + .allowComponent(ComponentName(this, TargetActivity::class.java)) + .allowAction(Intent.ACTION_VIEW) + .allowData { it.scheme == ContentResolver.SCHEME_CONTENT && it.authority == "$packageName.provider" } + .allowType("text/plain") + .allowExtra("user_display_name", String::class.java) { it.length <= 100 } // type and content + .build() + + val safeIntent = try { + val untrusted = IntentCompat.getParcelableExtra(intent, "EXTRA_NESTED_INTENT", Intent::class.java) + ?: return + sanitizer.sanitizeByThrowing(untrusted) + } catch (_: SecurityException) { + Log.w("SECURITY_ALERT", "Blocked non-allowlisted nested intent") + return + } catch (_: BadParcelableException) { // e.g. an extra naming an unknown Parcelable/Serializable + Log.w("SECURITY_ALERT", "Malformed nested intent") + return + } - // Option B: Silently filter and launch only the authorized parts (no exception thrown) - // val filteredIntent = sanitizer.sanitizeByFiltering(untrustedIntent) - // startActivity(filteredIntent) + // The sanitizer doesn't enforce presence; check what the target actually requires. + if (safeIntent.getStringExtra("user_display_name") == null) { + Log.w("SECURITY_ALERT", "Nested intent missing user_display_name") + return } + startActivity(safeIntent) } ``` @@ -387,12 +386,13 @@ override fun query( ### 7. Service caller signature verification -Verify the calling application's signature before binding to a service. +Verify the calling application's signing certificate on incoming cross-process +IPC calls in an exported bound service. - **Expected Inputs:** - - `intent` (`Intent`): The binding request intent. + - An incoming cross-process Binder transaction (for example, an AIDL `Stub` method call or `onTransact()`). - **Expected Outputs:** - - `IBinder`: Local binder instance if caller signature matches trusted partner; throws `SecurityException` otherwise. + - Executes the requested service operation if the calling UID's signing certificate matches the trusted partner hash; throws `SecurityException` otherwise. ```kotlin @@ -526,7 +526,7 @@ Use the following markdown template when reporting changes to developers: ## Antipatterns -- **NEVER** launch a nested `Intent` received from an untrusted source without verifying its target package and exported status. +- **NEVER** launch a nested `Intent` received from an untrusted source without verifying that its resolved component (`packageName` and `className`) matches an explicit allowlist and rejecting or stripping URI permission grant flags. - **NEVER** use sticky broadcasts (`sendStickyBroadcast`). - **NEVER** assume an exported component is safe because it runs in a background thread or performs internal checks. - **NEVER** expose sensitive functionalities (like SSO authentication or payment processors) to components without signature-level permission restrictions. @@ -546,5 +546,5 @@ Use the following markdown template when reporting changes to developers: - **MUST** use `PendingIntent.FLAG_IMMUTABLE` by default when constructing `PendingIntent` instances. - **MUST** protect exported `ContentProviders` with `readPermission` and `writePermission`. - **MUST** enforce parameterized selection structures in `ContentProvider` query/update methods. -- **MUST** verify the package signature fingerprint of binding applications at runtime inside exported services. +- **MUST** verify the calling application's signing certificate on incoming IPC transactions at runtime inside exported bound services using a cross-process interface (such as an AIDL `Stub` or `Messenger`). - **MUST** use `androidx.core.content.IntentSanitizer` to sanitize incoming dynamic intents before redirection, if AndroidX Core 1.9.0+ is imported in the project. diff --git a/.agents/skills/android-permissions-security/SKILL.md b/.agents/skills/android-permissions-security/SKILL.md index 4a84a3c..e7911ab 100644 --- a/.agents/skills/android-permissions-security/SKILL.md +++ b/.agents/skills/android-permissions-security/SKILL.md @@ -7,7 +7,7 @@ description: Audits, detects gaps, and remediates Android permissions and IPC co license: Complete terms in LICENSE.txt metadata: author: Google LLC - last-updated: '2026-09-25' + last-updated: '2026-10-22' keywords: - recipe - Android @@ -237,7 +237,7 @@ if (callingPackage != null && SignatureUtils.verifyPartnerPackage(this, callingP ### Secure pattern: Authenticate calling UID cryptographically 1. **Completely remove** all `intent.getStringExtra("calling_package")`, `intent.getStringExtra("sender")`, and `callingPackage == "..."` checks. -2. Obtain caller identity using `Binder.getCallingUid()`. +2. Obtain caller identity using `Binder.getCallingUid()` inside an active Binder IPC transaction (such as an AIDL `Stub` method or `ContentProvider` method). 3. Verify caller certificate using `PackageManager.hasSigningCertificate()` (API 28+) or `PackageManager.getPackagesForUid()`. ### Complete CallerVerifier implementation @@ -245,6 +245,7 @@ if (callingPackage != null && SignatureUtils.verifyPartnerPackage(this, callingP Apply this to the service that currently performs the spoofable check. - **Placement** : declare `CallerVerifier` in the **same file** as the service you are fixing (for example inside `SecureDataService.kt`). Do not move the verification into a new file and leave the original service untouched. +- **Active Binder IPC context** : `Binder.getCallingUid()` only returns the remote caller's UID during an active Binder transaction (never inside `Service.onStartCommand()` or `Service.onBind()`, where it returns `Process.myUid()`). Reject `callingUid == Process.myUid()` so calls outside an active IPC transaction or after `Binder.clearCallingIdentity()` fail closed instead of bypassing verification. - **Nullability** : `packageInfo.signatures` is `Array?`. Copy the `?: return false` guard exactly as written below. Iterating `packageInfo.signatures` directly fails to compile with "Non-nullable value required to call an 'iterator()' method in a for-loop." @@ -255,15 +256,13 @@ object CallerVerifier { fun isCallerAuthorized(context: Context): Boolean { val callingUid = Binder.getCallingUid() - if (callingUid == android.os.Process.myUid()) return true + if (callingUid == android.os.Process.myUid()) return false val pm = context.packageManager // Modern API 28+ check by UID: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { val certBytes = hexStringToByteArray(TRUSTED_PARTNER_SHA256) - if (pm.hasSigningCertificate(callingUid, certBytes, PackageManager.CERT_INPUT_SHA256)) { - return true - } + return pm.hasSigningCertificate(callingUid, certBytes, PackageManager.CERT_INPUT_SHA256) } // Fallback for legacy APIs: @@ -564,9 +563,13 @@ class ProtectedReceiver : BroadcastReceiver() {
-*Legacy compatibility (Pre-API 34)* : For apps targeting API 33 or lower, -attach an immutable `PendingIntent` extra (`FLAG_IMMUTABLE`) to the broadcast -and authenticate the sender using `pendingIntent.creatorPackage`. +*Legacy compatibility (Pre-API 34)* : On API 33 and lower, **never** authenticate +a broadcast sender using `PendingIntent.getCreatorPackage()` or +`PendingIntent.getCreatorUid()`, because any app that acquires a trusted app's +`PendingIntent` can attach and send it (see [Sender of Pending Intents](references/android/privacy-and-security/risks/sender-of-pending-intents.md)). +Instead, restrict the `` with a `signature` or `signature|knownSigner` +(API 31+) permission in the manifest; if `minSdkVersion` is lower than 31, use a +bound `Service` or `ContentProvider` and verify `Binder.getCallingUid()`. *** ** * ** *** @@ -588,6 +591,7 @@ and authenticate the sender using `pendingIntent.creatorPackage`. ### NEVER - **NEVER** extract or verify package names from Intent extras (`intent.getStringExtra("calling_package")`, `intent.getStringExtra("sender")`, `intent.getStringExtra("package")`). Always authenticate using `Binder.getCallingUid()` and certificates. +- **NEVER** authenticate a broadcast or IPC sender using `PendingIntent.getCreatorPackage()` or `PendingIntent.getCreatorUid()`. - **NEVER** remediate caller spoofing by merely adding manifest permissions while keeping spoofable intent extra checks. - **NEVER** use `checkCallingOrSelfPermission()` or `enforceCallingOrSelfPermission()` to guard external caller methods. Always use `enforceCallingPermission()` or `checkCallingPermission()`. - **NEVER** use deprecated `signatureOrSystem`. diff --git a/.agents/skills/android-permissions-security/references/android/privacy-and-security/risks/sender-of-pending-intents.md b/.agents/skills/android-permissions-security/references/android/privacy-and-security/risks/sender-of-pending-intents.md new file mode 100644 index 0000000..2385de6 --- /dev/null +++ b/.agents/skills/android-permissions-security/references/android/privacy-and-security/risks/sender-of-pending-intents.md @@ -0,0 +1,61 @@ +
+ +**OWASP category:** [MASVS-CODE: Code Quality](https://mas.owasp.org/MASVS/10-MASVS-CODE) + +## Overview + +Using [`PendingIntent.getCreator*()`](https://developer.android.com/reference/android/app/PendingIntent#public-methods_1) or [`PendingIntent.getTarget*()`](https://developer.android.com/reference/android/app/PendingIntent#public-methods_1) to determine whether to trust a PendingIntent's sender creates an +exploitation risk. + +[`PendingIntent.getCreator*()`](https://developer.android.com/reference/android/app/PendingIntent#public-methods_1) or +[`PendingIntent.getTarget*()`](https://developer.android.com/reference/android/app/PendingIntent#public-methods_1) returns the PendingIntent's +creator, which does not always match its sender. The creator may be trusted, but +the sender should **never** be trusted, as the sender might be a malicious app +that acquired another app's PendingIntent using a variety of mechanisms, for +example: + +- from [`NotificationListenerService`](https://developer.android.com/reference/android/service/notification/NotificationListenerService) +- legitimate use cases that are part of the vulnerable app. + +An example of a legitimate use of [`PendingIntent.getCreator*()`](https://developer.android.com/reference/android/app/PendingIntent#public-methods_1) or [`PendingIntent.getTarget*()`](https://developer.android.com/reference/android/app/PendingIntent#public-methods_1) would be to show +the icon of the app that will be started by the PendingIntent. + +## Impact + +Trusting a PendingIntent's sender because you queried (and trust) the creator +can lead to vulnerabilities. If an app trusts the PendingIntent's sender based +on its creator, and then shares its authentication or authorization logic, then +whenever the PendingIntent's sender is a malicious app, this would lead to an +authentication bypass or potentially even remote code execution based on +invalidated, untrusted input, depending on the implementation of the vulnerable +application's code. + +## Mitigations + +### Distinguish between sender and creator + +Any type of authentication or authorization logic performed when receiving a +PendingIntent must not be based on assumptions regarding the PendingIntent's +creator identified using either [`PendingIntent.getCreator*()`](https://developer.android.com/reference/android/app/PendingIntent#public-methods_1) +or [`PendingIntent.getTarget*()`](https://developer.android.com/reference/android/app/PendingIntent#public-methods_1). + +### Use alternative ways to validate callers + +If you need to authenticate the caller, instead of using PendingIntent, you +should use a Service or ContentProvider -- both allow fetching the caller UID +with [Binder.getCallingUid()](https://developer.android.com/reference/android/os/Binder#getCallingUid()) when you are in the context of dispatching an +incoming IPC. The UID can be queried later by using +[PackageManager.getPackagesForUid()](https://developer.android.com/reference/android/content/pm/PackageManager#getPackagesForUid(int)). + +Another approach, available from API level 34, would be to use +[BroadcastReceiver.getSentFromUid()](https://developer.android.com/reference/android/content/BroadcastReceiver#getSentFromUid()) or +[BroadcastReceiver.getSentFromPackage()](https://developer.android.com/reference/android/content/BroadcastReceiver#getSentFromPackage()) if the sender opted in to sharing +identity during broadcast using [BroadcastOptions.isShareIdentityEnabled()](https://developer.android.com/reference/android/app/BroadcastOptions#isShareIdentityEnabled()). + +You should always check if the calling package has the expected signature, as +sideloaded packages can have package names overlapping with ones from the Play +Store. + +## Resources + +- [Pending Intent](https://developer.android.com/reference/android/app/PendingIntent) \ No newline at end of file diff --git a/.agents/skills/android-profiler/SKILL.md b/.agents/skills/android-profiler/SKILL.md index 6cab558..159cb43 100644 --- a/.agents/skills/android-profiler/SKILL.md +++ b/.agents/skills/android-profiler/SKILL.md @@ -13,7 +13,7 @@ description: 'Records and analyzes Android performance data and diagnoses perfor license: Complete terms in LICENSE.txt metadata: author: Google LLC - last-updated: '2026-10-17' + last-updated: '2026-10-22' keywords: - android performance - debugging diff --git a/.agents/skills/android-profiler/references/perfetto/bin/trace_processor b/.agents/skills/android-profiler/references/perfetto/bin/trace_processor index 949ed07..31a2c30 100644 --- a/.agents/skills/android-profiler/references/perfetto/bin/trace_processor +++ b/.agents/skills/android-profiler/references/perfetto/bin/trace_processor @@ -30,17 +30,17 @@ exec python3 - "$@" <<'#'EOF # ----- Amalgamator: begin of python/perfetto/prebuilts/manifests/trace_processor_shell.py -# This file has been generated by: tools/release/roll-prebuilts v57.2 +# This file has been generated by: tools/release/roll-prebuilts v58.3 TRACE_PROCESSOR_SHELL_MANIFEST = [ { 'arch': 'mac-amd64', 'file_name': 'trace_processor_shell', - 'file_size': 13787208, + 'file_size': 14854504, 'url': ( - 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v57.2/mac-amd64/trace_processor_shell' + 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v58.3/mac-amd64/trace_processor_shell' ), 'sha256': ( - 'c0f61397901da47cbe1bb9a0843624f7c2038ac92176ce15e3736ce9aa0afef0' + 'faba0dbd8a1d362b712536f7740845ad11a0fa27f3bca666ab9559ceb307ac65' ), 'platform': 'darwin', 'machine': ['x86_64'], @@ -48,12 +48,12 @@ TRACE_PROCESSOR_SHELL_MANIFEST = [ { 'arch': 'mac-arm64', 'file_name': 'trace_processor_shell', - 'file_size': 12704456, + 'file_size': 13597976, 'url': ( - 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v57.2/mac-arm64/trace_processor_shell' + 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v58.3/mac-arm64/trace_processor_shell' ), 'sha256': ( - '98a41b80e9f60da0373d64aff6455681f8c26b7c391ae5736324a5b11e3dacc2' + '99c75cf71711af7206fac27ae96017d91255a19f6a1afbd635dcf653821eda5d' ), 'platform': 'darwin', 'machine': ['arm64'], @@ -61,12 +61,12 @@ TRACE_PROCESSOR_SHELL_MANIFEST = [ { 'arch': 'linux-amd64', 'file_name': 'trace_processor_shell', - 'file_size': 14008280, + 'file_size': 14897560, 'url': ( - 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v57.2/linux-amd64/trace_processor_shell' + 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v58.3/linux-amd64/trace_processor_shell' ), 'sha256': ( - '55ba613fc6d4f71df81eee2dbfc293020063655c241b3e314bff75345b802684' + 'f13baf75f5655109a637b041e7f9c78ebe76d7c0f030cd6d611d5679cfcb4fb9' ), 'platform': 'linux', 'machine': ['x86_64'], @@ -74,12 +74,12 @@ TRACE_PROCESSOR_SHELL_MANIFEST = [ { 'arch': 'linux-arm', 'file_name': 'trace_processor_shell', - 'file_size': 10321532, + 'file_size': 10962664, 'url': ( - 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v57.2/linux-arm/trace_processor_shell' + 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v58.3/linux-arm/trace_processor_shell' ), 'sha256': ( - '0f3221a1fb6dbe584c4bdc4a7541bcf15e6e5c5dde5e4910744dfb8913ee62c5' + 'a1b7672785cd3519c24783a95fac65534ecf321014db72abd969ebd7bb54db2a' ), 'platform': 'linux', 'machine': ['armv6l', 'armv7l', 'armv8l'], @@ -87,12 +87,12 @@ TRACE_PROCESSOR_SHELL_MANIFEST = [ { 'arch': 'linux-arm64', 'file_name': 'trace_processor_shell', - 'file_size': 13297064, + 'file_size': 14086160, 'url': ( - 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v57.2/linux-arm64/trace_processor_shell' + 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v58.3/linux-arm64/trace_processor_shell' ), 'sha256': ( - '1dcc1d9aaff2eb92e8bc58f1957e4e445600294bd61dbc09345c1018c5ff0868' + 'c19a09baf07695829339f83dd4a293a2194a6013aeef73c4701f10f31df9cd74' ), 'platform': 'linux', 'machine': ['aarch64'], @@ -100,56 +100,56 @@ TRACE_PROCESSOR_SHELL_MANIFEST = [ { 'arch': 'android-arm', 'file_name': 'trace_processor_shell', - 'file_size': 10293452, + 'file_size': 10935412, 'url': ( - 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v57.2/android-arm/trace_processor_shell' + 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v58.3/android-arm/trace_processor_shell' ), 'sha256': ( - 'a92d4138966a494bbf017d054fd9c859bf1a3deeca56dc6f84715292d87b6073' + '250dbe2c899c00d804bd11b339e09b9603a03da93bf8d272b3b84a8b53161ee8' ), }, { 'arch': 'android-arm64', 'file_name': 'trace_processor_shell', - 'file_size': 13098064, + 'file_size': 13838680, 'url': ( - 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v57.2/android-arm64/trace_processor_shell' + 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v58.3/android-arm64/trace_processor_shell' ), 'sha256': ( - '05e95758a26cf57fb8d552d3c1d532486681a6ecc4ee705a484a8deb4d092d09' + '1a9e98aa245831a8bd3c0e3056b0261c566ce9a1b51365283d46654327df4664' ), }, { 'arch': 'android-x86', 'file_name': 'trace_processor_shell', - 'file_size': 14811628, + 'file_size': 15814036, 'url': ( - 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v57.2/android-x86/trace_processor_shell' + 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v58.3/android-x86/trace_processor_shell' ), 'sha256': ( - '21b72f5f2821f8e6c050ee3788df9043af1b83b5e90726c343405b4f3c9837a3' + '87083c4e46a06a0290f8c355b8c1fa7b29f7c82dd3eb3e628f45f9a7aafb03a1' ), }, { 'arch': 'android-x64', 'file_name': 'trace_processor_shell', - 'file_size': 13724184, + 'file_size': 14611448, 'url': ( - 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v57.2/android-x64/trace_processor_shell' + 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v58.3/android-x64/trace_processor_shell' ), 'sha256': ( - '0e3fe6d80fc00891035e09b7dbf7fa6b2e9752282ac4b8f1ebe792377877f7d8' + '648debede565a249abd366ec3bfe976737ca5a68c7f5a3a5ae95c74633cda750' ), }, { 'arch': 'windows-amd64', 'file_name': 'trace_processor_shell.exe', - 'file_size': 13617152, + 'file_size': 14439936, 'url': ( - 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v57.2/windows-amd64/trace_processor_shell.exe' + 'https://commondatastorage.googleapis.com/perfetto-luci-artifacts/v58.3/windows-amd64/trace_processor_shell.exe' ), 'sha256': ( - '100334b6091596fbc97f872556849a5747bf47a7f7190c485ba8cea8d2409c7b' + '8868f0d03fba8953247c025d48183f16e2445c983fe1b843e8b13ca2d4319d35' ), 'platform': 'win32', 'machine': ['amd64'], diff --git a/.agents/skills/display-glasses-with-jetpack-compose-glimmer/SKILL.md b/.agents/skills/display-glasses-with-jetpack-compose-glimmer/SKILL.md index dfebcbd..b34ee64 100644 --- a/.agents/skills/display-glasses-with-jetpack-compose-glimmer/SKILL.md +++ b/.agents/skills/display-glasses-with-jetpack-compose-glimmer/SKILL.md @@ -10,7 +10,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-10-17' + last-updated: '2026-10-22' keywords: - Jetpack Compose Glimmer - display glasses @@ -24,314 +24,575 @@ metadata: | Term | Definition | |---|---| -| **Intelligent Eyewear** | All-day wear, hands-free devices that provide access to information. Equipped with speakers, a camera, and a microphone. Some are audio-only (audio glasses), and some also have a display (display glasses). | -| **Display glasses** | Audio glasses with the addition of a private display in lenses for glanceable visuals that harmonize with audio output. | -| **Jetpack Compose Glimmer** | A Compose UI toolkit for building augmented Android XR experiences, optimized for display glasses. It provides components, theming, and behaviors for transparent displays. | -| **Projected Activity (Glasses Activity)** | An Android `Activity` that runs on a host device (phone) but its UI and interactions are projected to a connected, intelligent eyewear device (audio or display glasses). | -| **Projected Device** | An XR device connected to an Android-powered device (host). Host projects the application content to the Projected device and let users interact with it. | -| **GlimmerTheme** | The root provider for styling tokens, including GlimmerColors, GlimmerTypography, and GlimmerShapes. | -| **Additive Display** | A display technology where black (#000000) is rendered as 100% transparent. UI is built by adding light to the environment. Display glasses have an additive display. | -| **Augmented Experiences** | Android XR experiences that enhance a user's focus and presence in the real world. They are lightweight and additive, helping users while they are on-the-go | -| **Visual Angle** | A unit of measurement for perceived size in XR. The minimum readable text size is 0.6 degrees (approx. 18sp at 1 m). | +| **Intelligent eyewear** | Hands-free eyewear with speakers, camera, and microphone (audio-only or display glasses). | +| **Display glasses** | Eyewear with a private in-lens display for glanceable visuals that pair with audio. | +| **Jetpack Compose Glimmer** | Compose UI toolkit providing components, theming, and focus behavior for display glasses. | +| **Projected activity** | An `Activity` running on a phone host whose UI and input are projected to connected eyewear. | +| **Projected device** | An XR eyewear device connected to an Android host phone that projects app content to it. | +| **GlimmerTheme** | Root styling provider for `GlimmerColors`, `GlimmerTypography`, and `GlimmerShapes`. | +| **Additive display** | Display where black (`#000000`) renders as 100% transparent by adding light to the scene. | +| **Augmented experiences** | Lightweight, additive Android XR experiences that help users while they're on the go. | +| **Visual angle** | Perceived size in XR; minimum readable text is 0.6 degrees (about 18sp at 1 m). | ## Prerequisites -- Mobile project must target `compileSdk` 37 or higher. If the `compileSdk` is lower than 37, increase the `compileSdk` to 37. -- Ensure you are using the latest library dependencies from [Create your first - activity for intelligent eyewear](https://developer.android.com/develop/xr/jetpack-xr-sdk/ai-glasses/first-activity). +- Target `compileSdk` 37 or higher (`compileSdk = 37` or `compileSdk { version + = release(37) }`). Update `compileSdk` to 37 if lower. +- When adding dependencies, follow the instructions in [Create your first + activity for intelligent eyewear](https://developer.android.com/develop/xr/jetpack-xr-sdk/ai-glasses/first-activity): + - `androidx.xr.glimmer:glimmer` (minimum `1.0.0-alpha21`, or catalog alias `libs.androidx.glimmer`) + - `androidx.xr.glimmer:glimmer-google-fonts` (minimum `1.0.0-alpha21`, or catalog alias `libs.androidx.glimmer.google.fonts`) + - `androidx.xr.projected:projected` (minimum `1.0.0-alpha13`, or catalog alias `libs.androidx.xr.projected` or `libs.androidx.projected`) + - Check `gradle/libs.versions.toml` first; if catalog entries exist, use them in `app/build.gradle.kts` and verify their versions meet the minimums. Never guess `1.0.0-alpha01` for `glimmer-google-fonts`. -## Core Constraints +## Core constraints and limitations -- **Don't:** Use `MaterialTheme` or Material Components. -- **Do:** Use `GlimmerTheme` and Jetpack Compose Glimmer Components. -- **Do:** Use `createGoogleSansFlexTypography()` from `androidx.xr.glimmer:glimmer-google-fonts` as the `Typography` value for `GlimmerTheme` to ensure that consistent typography is applied throughout the components. - -## Limitations - -- Specific hardware device models or their unique capabilities are not detailed. -- **Elevation:** Standard Material 3 shadow or elevation modifiers are not supported. You must use `ShadowScope` or `Depth` tokens from Jetpack Compose Glimmer. -- **Minimum Size:** The absolute minimum size for readable text is 18sp (0.6°). Anything smaller will fail legibility checks. -- **Text Weight:** Avoid "Thin" or "Hairline" weights; they cause shimmering and aliasing on additive AR lenses. +- **Don't** : Use `MaterialTheme` or Material 3 components (`androidx.compose.material3.*`) inside Projected Activities or Glimmer UI. +- **Do** : Use `GlimmerTheme` and Glimmer components (`androidx.xr.glimmer.*`). +- **Do** : Always pass `createGoogleSansFlexTypography()` from `androidx.xr.glimmer.googlefonts` as `typography` in `GlimmerTheme`. +- Specific hardware device models or unique capabilities aren't detailed. +- **Elevation** : Material 3 shadow or elevation modifiers aren't supported. Use `ShadowScope` or `Depth` tokens from Jetpack Compose Glimmer. +- **Typography limits**: Minimum readable text size is 18sp (0.6°). Avoid "Thin" or "Hairline" weights, which shimmer on additive lenses. ## 1. Set up dependencies -- **Setup Projected Activity:** First, you need to create a new projected activity for your app. If the project doesn't already have one, see [Create - your first activity for intelligent eyewear](https://developer.android.com/develop/xr/jetpack-xr-sdk/ai-glasses/first-activity). Use [references/projectedcontext-source.md](references/projectedcontext-source.md) to launch the Glasses Projected activity on the Projected Device. Ensure that you specify `android.hardware.display.category.XR_PROJECTED` for the `android:requiredDisplayCategory` attribute in app manifest to tell the system that this is a projected activity. -- **Mobile App Integration:** If the project contains an existing mobile app, you must create a new Glasses Activity dedicated to rendering Glimmer UI. For detailed configuration, heavily reference [Create your first activity - for intelligent eyewear](https://developer.android.com/develop/xr/jetpack-xr-sdk/ai-glasses/first-activity). If there isn't already a method to launch the Glasses Activity, add a button to the existing mobile app UI labeled "Launch on Glasses" that uses `ProjectedContext` to launch the Glasses Activity on the glasses. Place the 'Launch on Glasses' button as a screen-level FloatingActionButton, in the TopAppBar, or in the top navigation bar to ensure users discover the projection capability. If the glasses aren't connected, disable the button. Don't launch the Glasses Activity on the phone, only on the display glasses. If it makes sense to automatically launch the Glasses Activity without an explicit launch button, then do so. -- **UI Library:** Identify if the project has the `androidx.xr.glimmer:glimmer` library, if not it must be added to the project. See [Declaring Jetpack Compose Glimmer Dependencies](https://developer.android.com/jetpack/androidx/releases/xr-glimmer#declaring_dependencies) to fetch the latest dependency version. -- **Theming:** All Glimmer components must be wrapped within the `GlimmerTheme` composable to ensure correct token resolution. -- **Mandatory black background:** Display glasses use additive displays. Any non-black color in the background blocks the real world. **You must always** set a pure black background (`Modifier.background(Color.Black)`) on the root container of your Projected Activity. -- **Font:** The default font is Google Sans Flex. Use `androidx.xr.glimmer.googlefonts` library with the default type styles unless otherwise specified. Use `createGoogleSansFlexTypography` to create a `Typography` instance with the Google Sans Flex configuration. Provide this `Typography` instance as normal through `GlimmerTheme`. Use [references/glimmersansflextypography-source.md](references/glimmersansflextypography-source.md) for configuration. -- **Hardware Capabilities:** Different types of intelligent eyewear devices have different capabilities. To check for these at runtime, see the [Check - device capabilities at runtime for intelligent eyewear](https://developer.android.com/develop/xr/jetpack-xr-sdk/ai-glasses/check-capabilities). -- **Hardware Permissions:** To request hardware permissions like the microphone and camera, see the [Request hardware permissions for - intelligent eyewear](references/android/develop/xr/jetpack-xr-sdk/request-hardware-permissions.md). -- **Hardware Access:** To use the glasses camera, sensors, or access the phone's hardware, see the [Use a projected context to access hardware on - intelligent eyewear](references/android/develop/xr/jetpack-xr-sdk/access-hardware-projected-context.md). +- **Set up projected activity** : When creating a projected activity, follow + [Create your first activity for intelligent eyewear](https://developer.android.com/develop/xr/jetpack-xr-sdk/ai-glasses/first-activity) and + [projectedcontext-source.md](references/projectedcontext-source.md). Set `android:requiredDisplayCategory` to + `android.hardware.display.category.XR_PROJECTED` in `AndroidManifest.xml`: + + + ```xml + + + + + + + ``` + +
+ +- **Mobile app integration** : In the host phone app, add a button with + `Text("Launch on Glasses")`. Observe + `ProjectedContext.isProjectedDeviceConnected(context, coroutineContext)` + using + `collectAsStateWithLifecycle(initialValue = false)` to set the button's + `enabled` state so it's disabled when glasses are disconnected. When + clicked, launch `GlassesMainActivity` on the glasses display using + `ProjectedContext.createProjectedActivityOptions(context).toBundle()` as + shown in the following snippet: + + + ```kotlin + val options = ProjectedContext.createProjectedActivityOptions(context) + val intent = Intent(context, ProjectedMainActivity::class.java) + context.startActivity(intent, options.toBundle()) + ``` + +
+ +- **UI library and font** : When declaring the Glimmer, Google Fonts, and + Projected artifacts in `app/build.gradle.kts`, follow [Declaring Jetpack + Compose Glimmer Dependencies](https://developer.android.com/jetpack/androidx/releases/xr-glimmer), and follow + [glimmersansflextypography-source.md](references/glimmersansflextypography-source.md) when configuring Google Sans Flex + using `createGoogleSansFlexTypography`. + +- **Theming, initial focus, and black background** : On additive displays, pure + black (`Color.Black` or `GlimmerTheme.colors.background`) renders as 100% + transparent. In `onCreate` (annotated with + `@OptIn(ExperimentalComposeUiApi::class)`), set + `ComposeUiFlags.isInitialFocusOnFocusableAvailable = true` + (`androidx.compose.ui.ComposeUiFlags`), wrap content in + `GlimmerTheme(typography = createGoogleSansFlexTypography())`, and apply + `Modifier.background(Color.Black)` (or `GlimmerTheme.colors.background`) to + the root container: + + + ```kotlin + @OptIn(ExperimentalProjectedApi::class) + class ProjectedMainActivity : ComponentActivity() { + + private var displayController: ProjectedDisplayController? = null + private var isVisualUiSupported by mutableStateOf(false) + private var areVisualsOn by mutableStateOf(true) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + displayController?.close() + displayController = null + } + }) + + initializeGlassesFeatures() + + setContent { + GlimmerTheme { + HomeScreen( + areVisualsOn = areVisualsOn, + isVisualUiSupported = isVisualUiSupported, + onClose = { finish() } + ) + } + } + } + + private fun initializeGlassesFeatures() { + lifecycleScope.launch { + // Check device capabilities + val projectedDeviceController = ProjectedDeviceController.create(this@ProjectedMainActivity) + isVisualUiSupported = projectedDeviceController.capabilities.contains(CAPABILITY_VISUAL_UI) + + val controller = ProjectedDisplayController.create(this@ProjectedMainActivity) + displayController = controller + val observer = GlassesLifecycleObserver( + context = this@ProjectedMainActivity, + controller = controller, + onVisualsChanged = { visualsOn -> areVisualsOn = visualsOn } + ) + lifecycle.addObserver(observer) + } + } + } + ``` + +
+ +- **Hardware capabilities, permissions, and access** : When checking hardware + capabilities, requesting permissions, or accessing glasses hardware, follow + [Check device capabilities at runtime](https://developer.android.com/develop/xr/jetpack-xr-sdk/ai-glasses/check-capabilities), [Request hardware + permissions](references/android/develop/xr/jetpack-xr-sdk/request-hardware-permissions.md), and [Use a projected context to access hardware](references/android/develop/xr/jetpack-xr-sdk/access-hardware-projected-context.md). ## 2. Minimize and translate the UI -- For display glasses, build UI using components from the Jetpack Compose Glimmer framework. -- Use depth to communicate element priority and hierarchy. -- Design from the bottom up, trying to minimize how much of the real world you cover. Always bottom align UI to the glasses display. -- **One Thing at a Time:** Prioritize the user's awareness of the real world. Show only one primary piece of information at a time (for example, using a `Stack`) to minimize obstruction of the user's field of view. Avoid multiple simultaneous cards. -- **Color Contrast:** Ensure at least a 70% tone difference between foreground and background using the HCT color space. For calculation metrics, use [references/material-hct-source.md](references/material-hct-source.md). +- Build UI with Jetpack Compose Glimmer components and use depth for priority. +- Design from the bottom up and always bottom-align UI (`Alignment.BottomCenter`). +- **One thing at a time** : Show one primary piece of information at a time (for example, using `VerticalStack`) and avoid multiple simultaneous cards. +- **Color contrast** : When choosing colors, follow [material-hct-source.md](references/material-hct-source.md) to maintain at least a 70% tone difference between foreground and background in HCT. ## 3. Map input controls -- Map app interactions, such as tap and swipe, to the available hardware controls on the glasses, such as the touchpad. -- Inputs are one-dimensional; users must make one control input at a time. -- Avoid nesting scrolling controls. -- Jetpack Compose Glimmer components are designed to work with standard input methods, such as a tap or swipe on the glasses' touchpad. +- Map tap and swipe interactions to the glasses touchpad. Inputs are one-dimensional; avoid nesting scrolling controls. - Use System Back to dismiss temporary states or detailed views. -- To add input, focus, tap, swipe to your Glasses UI, follow [Focus in Jetpack - Compose Glimmer](references/android/develop/xr/jetpack-xr-sdk/jetpack-compose-glimmer/focus.md). - - For a detailed breakdown of hardware inputs, see [Hardware Controls for - display glasses](references/android/design/ui/ai-glasses/guides/interaction/inputs.md) +- When implementing focus navigation and hardware input handling, follow [Focus in Jetpack Compose Glimmer](references/android/develop/xr/jetpack-xr-sdk/jetpack-compose-glimmer/focus.md) and [Hardware Controls for display + glasses](references/android/design/ui/ai-glasses/guides/interaction/inputs.md). Focus movement is continuous for scrollable containers and discrete for elements like button rows. ## 4. Build with Jetpack Compose Glimmer -Jetpack Compose Glimmer is the UI toolkit for building augmented experiences on -display glasses. - -### Key Features - -- **Glimmer Theming:** A simplified glasses-specific theme for optimal visibility. -- **Pre-compatible Input:** Supports tap and swipe by default. -- **Pre-built Components:** Provides optimized composables like `Card`, `ListItem`, `Button`, etc. - -### Focus Management in Glimmer - -- Jetpack Compose Glimmer uses a one-dimensional focus search. -- Focus movement is continuous for scrollable containers and discrete for elements like a row of buttons. -- To enable the system to automatically set initial focus, you must set the `isInitialFocusOnFocusableAvailable` flag to `true` in your activity's `onCreate` method. For more information on how to implement, see [Focus in - Jetpack Compose Glimmer](references/android/develop/xr/jetpack-xr-sdk/jetpack-compose-glimmer/focus.md). - -### Implementing Glimmer Styles +### Implementing Glimmer styles -Glimmer styles are accessed through the `GlimmerTheme` object. Use -[references/glimmertheme-source.md](references/glimmertheme-source.md) for reference. +When accessing theme tokens using `GlimmerTheme`, follow +[glimmertheme-source.md](references/glimmertheme-source.md). | Category | Token | Value / Role | |---|---|---| -| **Color** | primary | #9BBFFF (Focal color) | -| **Color** | secondary | #4C88E9 (Focal color) | -| **Color** | surface | #262626 (Transparent base - renders as transparent) | -| **Color** | outline | #606460 (3.dp border color) | -| **Shape** | Standard | `RoundedCornerShape(36.dp)` | -| **Shape** | Small | `RoundedCornerShape(12.dp)` | - -#### Typography Scale (Google Sans Flex) - -**Strict default:** When creating Glimmer UI you must use Google Sans Flex -unless a custom brand typeface is explicitly specified. -**Variable Font Settings:** As Google Sans Flex is a variable font, you must -configure the following axes: - -- **Roundness (`ROND`):** Always set to `100f` for the signature rounded appearance. -- **Width (`wdth`):** Set to `100f`. -- **Optical Size (`opsz`):** Set to `9f`. -- **Weight (`wght`):** Use specific values for different roles (Title: `725f`, Body: `520f`, Caption: `650f`). - -| Style Name | Size / Line-Height | Weight Axis | Width | Roundness | Optical size | -|---|---|---|---|---|---| -| **Title Large** | 30.sp / 36.sp | 725 | 100 | 100 | 9 | -| **Title Medium** | 24.sp / 32.sp | 725 | 100 | 100 | 9 | -| **Title Small** | 20.sp / 28.sp | 725 | 100 | 100 | 9 | -| **Body Large** | 30.sp / 36.sp | 520 | 100 | 100 | 9 | -| **Body Medium** | 24.sp / 32.sp | 520 | 100 | 100 | 9 | -| **Body Small** | 20.sp / 28.sp | 520 | 100 | 100 | 9 | -| **Caption** | 18.sp / 28.sp | 650 | 100 | 100 | 9 | - -#### Depth Levels - -Simulate depth on display glasses using shadows to establish a sense of -hierarchy through varying levels of emphasis. The Jetpack Compose Glimmer -controls use `DepthEffect` with 5 preset `DepthEffectLevels`. Use -[references/deptheffect-source.md](references/deptheffect-source.md) and -[references/deptheffectlevels-source.md](references/deptheffectlevels-source.md) for reference. - -Some examples: - -| Level | Usage | -|---|---| -| **level1** | Standard rest state for cards and persistent background UI. | -| **level2** | Standard focus/pressed state for buttons and interactive cards. | -| **ExtraSmall** | 4.dp | - -### Implementing Jetpack Compose Glimmer Components - -#### Cards - -Cards are a fundamental surface-based container in Glimmer used to group related -content, such as text, images, icons and actions into a single focal point. They -establish a clear depth plane (Z-axis) in the Glasses environment, providing a -stable background for text, images, and icons. Never embed a card within a List -Item. - -##### Core Implementation Logic - -- **Surface Hierarchy:** Cards are designed to sit on a base surface or within a list. They provide an automatic visual feedback when focused if an `onClick` lambda is provided. -- **Interactivity:** - - **IF** the entire Card serves as a single trigger (e.g., a media item or a setting): **THEN** provide an `onClick` lambda to enable focus effects. - - **ELSE:** Leave `onClick` as `null` if the Card contains multiple internal interactive elements (like separate buttons or switches) to avoid focus contention. - -##### Technical Documentation Links - -If you are creating a Glimmer Card component, read the: - -- **Developer Guidance:** [Jetpack Compose Glimmer: Cards](references/android/develop/xr/jetpack-xr-sdk/jetpack-compose-glimmer/cards.md) -- **API Source Code:** Use [references/card-source.md](references/card-source.md). -- **Implementation Samples:** Use [references/card-samples-source.md](references/card-samples-source.md). (Basic and Interactive Card usage) - -#### Buttons - -Buttons are the primary triggers for discrete actions in Glimmer. They are -specifically optimized for the display glasses focus model, where a focus -highlight is added when focus is moved to the button using the touchpad or other -methods. - -##### Core Implementation Logic - -- There are two types of buttons to choose from in Glimmer: - - **Standard buttons** (Required text label with optional leading or trailing icons). Use this when there is more space in the UI, or when the meaning of the button isn't clear without text. - - **Icon buttons** (icon only). Only use icon buttons when the icon is clearly understandable. -- Both icon and standard buttons have default and toggle variants: - - Default (use for single actions like "buy" or "navigate") - - Toggle (use for things with selected states like mute buttons) - -##### Technical Documentation Links - -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](references/button-samples-source.md). - -#### Title Chips - -Chips are a pill-shaped, specialized labeling component designed to sit above a -`Card` or a group of content to provide a title. Use title chips to display -concise information like a short title, a name, or a status. +| **Color** | primary / secondary | `#9BBFFF` / `#4C88E9` (Focal colors) | +| **Color** | positive / negative | `#7AD48F` (Confirmation) / `#F57082` (Alert) | +| **Color** | background | `#000000` (Pure black, renders 100% transparent) | +| **Color** | surface | `#262626` (Use `SurfaceDefaults.border` for borders) | +| **Shape** | Standard / Small | `RoundedCornerShape(36.dp)` / `RoundedCornerShape(12.dp)` | -##### Guidelines and usage +#### Typography scale (Google Sans Flex) and depth levels -- **Spatial Spacing** : When using a standalone `TitleChip` preceding content, you must use `TitleChipDefaults.AssociatedContentSpacing` (8.dp) to maintain the visual hierarchy. -- **Interactivity:** Title chips are purely for informational purposes, they cannot be targeted or activated for navigation. -- **Layout** Always center text in a title chip. Never let the title chip go to two lines, and truncate extra words. Keep the label to three words or less. +Use Google Sans Flex with variable axes `ROND` = `100f`, `wdth` = `100f`, `opsz` += `9f`, and `wght` by role: -##### Technical Documentation Links - -If you are creating a Glimmer Title Chip component, read the: - -- **Developer Guidance:** [Jetpack Compose Glimmer: Title Chips](references/android/develop/xr/jetpack-xr-sdk/jetpack-compose-glimmer/title-chips.md) -- **API Source Code:** Use [references/titlechip-source.md](references/titlechip-source.md). -- **Implementation Samples:** Use [references/titlechip-samples-source.md](references/titlechip-samples-source.md). (Title Chips above group content) +| Style | Size / Line-Height | Weight | +|---|---|---| +| **Title Large / Medium / Small** | 30/36.sp, 24/32.sp, 20/28.sp | 725 | +| **Body Large / Medium / Small** | 30/36.sp, 24/32.sp, 20/28.sp | 520 | +| **Caption** | 18.sp / 28.sp | 650 | + +When simulating depth with `DepthEffect` and `DepthEffectLevels`, follow +[deptheffect-source.md](references/deptheffect-source.md) and [deptheffectlevels-source.md](references/deptheffectlevels-source.md): use `level1` +for resting cards and background UI, and `level2` for focused or pressed +interactive UI. + +### Implementing Jetpack Compose Glimmer components + +#### Cards, ActionCards, ImageCards, and ButtonGroups + +Cards (`androidx.xr.glimmer.Card`) group related content into a single focal +point. Never embed a `Card` within a `ListItem`. When implementing cards, follow +the instructions in [Jetpack Compose Glimmer: Cards](references/android/develop/xr/jetpack-xr-sdk/jetpack-compose-glimmer/cards.md), [card-source.md](references/card-source.md), +and [card-samples-source.md](references/card-samples-source.md). + +- **Clickable versus non-clickable card** : Pass `onClick = { ... }` if the entire `Card` acts as a trigger; omit `onClick` if informational. Control focus on non-clickable cards with `focusable: Boolean = true` (such as `Card(focusable = false)`). +- **Single primary action (`ActionCard`)** : When a card has a single primary + button action anchored to its bottom edge, use + `androidx.xr.glimmer.ActionCard` instead of placing a `Button` inside + `Card`. Its `action` button takes focus directly: + + + ```kotlin + ActionCard( + action = { + Button(onClick = {}, trailingIcon = { Icon(FavoriteIcon, "Localized description") }) { + Text("Send") + } + } + ) { + Text("This is a card with a title, leading icon, header image, and action") + } + ``` + +
+ +- **Multiple actions (`Card` and `ButtonGroup`)---avoiding focus contention** : + When pairing a card with multiple action buttons, use + `androidx.xr.glimmer.ButtonGroup` (with optional + `rememberButtonGroupState`). **Never nest `ButtonGroup` inside `Card { ... + }`.** Place a non-focusable `Card(focusable = false)` (without `onClick`) + **above** `ButtonGroup` as a sibling in a `Column` so the card and buttons + don't compete for focus: + + + ```kotlin + ButtonGroup(modifier = Modifier.fillMaxWidth()) { + Button(onClick = {}) { Text("Button 1") } + Button(onClick = {}) { Text("Button 2") } + Button(onClick = {}) { Text("Button 3") } + Button(onClick = {}) { Text("Button 4") } + Button(onClick = {}) { Text("Button 5") } + } + ``` + +
+ +- **Image cards** : Use `ImageCard`, `LeadingImageCard`, or `TrailingImageCard` + to display prominent imagery alongside card text. + +#### Buttons and ToggleButtons + +When implementing buttons or toggle buttons, follow the instructions in [Jetpack +Compose Glimmer: Buttons](references/android/develop/xr/jetpack-xr-sdk/jetpack-compose-glimmer/buttons.md), [button-source.md](references/button-source.md), and +[button-samples-source.md](references/button-samples-source.md). + +- **Standard and icon buttons** : Use `Button` for text actions (with optional `leadingIcon`/`trailingIcon`) and `IconButton` for icon-only triggers (such as media next-track or previous-track buttons). +- **Toggle buttons (`ToggleButton` and `IconToggleButton`)** : Use + `ToggleButton` or `IconToggleButton` for stateful on/off or play/pause + controls that morph based on `checked`: + + + ```kotlin + @Composable + fun ToggleButtonWithLeadingIconSample() { + var checked by remember { mutableStateOf(false) } + + ToggleButton( + checked = checked, + leadingIcon = { + Icon( + if (checked) FavoriteIcon else OutlinedFavoriteIcon, + contentDescription = "Toggle favorite" + ) + }, + onCheckedChange = { checked = it } + ) { + Text(if (checked) "On" else "Off") + } + } + ``` + +
+ +#### Title chips + +`TitleChip` (`androidx.xr.glimmer.TitleChip`) is a non-interactive pill header +(three words or fewer, single line). When implementing title chips, follow the +instructions in [Jetpack Compose Glimmer: Title Chips](references/android/develop/xr/jetpack-xr-sdk/jetpack-compose-glimmer/title-chips.md), +[titlechip-source.md](references/titlechip-source.md), and [titlechip-samples-source.md](references/titlechip-samples-source.md). + +- **Spatial spacing** : When placing a standalone `TitleChip` above a `Card` or + `ActionCard`, center the `TitleChip` horizontally and separate it from the + card using `TitleChipDefaults.associatedContentSpacing` (using `Spacer` or + `Arrangement.spacedBy(TitleChipDefaults.associatedContentSpacing)`): + + + ```kotlin + @Composable + fun TitleChipWithCardSample() { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + TitleChip { Text("Title Chip") } + + Spacer(Modifier.height(TitleChipDefaults.associatedContentSpacing)) + + Card( + title = { Text("Title") }, + subtitle = { Text("Subtitle") }, + leadingIcon = { Icon(FavoriteIcon, "Localized description") }, + ) { + Text("Card Content") + } + } + } + ``` + +
#### Icons -Icons are visual symbols used to represent concepts, actions, or status in a -concise way. In Glimmer, icons and icon buttons are optimized for the XR -environment, providing clear visibility on additive displays and gaze-responsive -feedback for interactive elements. - -##### Guidelines and usage - -- **Sizing:** Use predefined `IconSizes` (e.g., Standard, Large) to ensure icons remain legible and meet the minimum touch target requirements for the XR environment. -- **Interactivity:** - - **If** an icon serves as a trigger: **Then** you must use an `IconButton` to provide the automatic visual feedback and required tap-target padding. - - **ELSE:** Use a standalone `Icon` for non-interactive indicators or status symbols. -- **Contrast:** **NEVER** use pure black (#000000) for icon tints; always use themed colors or standard Glimmer content colors to ensure the icon is visible against dark or real-world backgrounds. -- **Icon Library** The default icon library is [Material Symbols](https://fonts.google.com/icons?icon.style=Rounded) with 600 weight, Rounded, Unfilled, unless otherwise specified. - -##### Technical Documentation Links +When adding icons or icon buttons, follow the instructions in [Jetpack Compose +Glimmer: Icons](references/android/develop/xr/jetpack-xr-sdk/jetpack-compose-glimmer/icons.md), [icon-source.md](references/icon-source.md), [iconbutton-source.md](references/iconbutton-source.md), and +[iconsizes-source.md](references/iconsizes-source.md). -If you are creating a Glimmer Icon component, read the: - -- **Developer Guidance:** [Jetpack Compose Glimmer: Icons](references/android/develop/xr/jetpack-xr-sdk/jetpack-compose-glimmer/icons.md) -- **API Source Code (Icon):** Use [references/icon-source.md](references/icon-source.md). -- **API Source Code (IconButton):** Use [references/iconbutton-source.md](references/iconbutton-source.md). -- **API Source Code (IconSizes):** Use [references/iconsizes-source.md](references/iconsizes-source.md). +- **Sizing and interactivity** : Size icons with `GlimmerTheme.iconSizes` (`small`, `medium`, `large`). Use `IconButton` or `IconToggleButton` for triggers and `Icon` for status indicators. +- **Contrast and library** : **Never** use pure black (`Color.Black`) for icon tints; black renders as transparent. Use [Material Symbols](https://fonts.google.com/icons?icon.style=Rounded) (600 weight, Rounded, Unfilled). If `androidx.compose.material:material-icons-*` isn't in `app/build.gradle.kts`, build vectors with `ImageVector.Builder` or `painterResource` instead of importing `androidx.compose.material.icons.*`. #### Lists -Lists are containers that allow you to navigate between and see multiple items -on glasses. If your use case works with only seeing one item in the list at a -time, use a Glimmer Stack. Use lists with a Title Chip when the list -items are similar in type. Also use a Glimmer Stack if the items are of -different types. - -##### Guidelines and usage - -- **ListItem Slots:** Use the `ListItem` composable for rows. It provides predefined slots. Use [references/listitem-source.md](references/listitem-source.md) for reference. -- **Visual Consistency:** When building lists of similar items, always use a consistent background color (typically `GlimmerTheme.colors.surface`) and corner radius (standard 36.dp) for every item. Don't vary these unless you are visually grouping different *types* of content. -- **Integrated Title Chips:** Glimmer Lists support integrated title chips. **IF** you need a section header within a list: **THEN** enable the integrated title chip rather than adding a standalone `TitleChip` to maintain spatial consistency. -- **Vertical Arrangement:** ALWAYS use `verticalArrangement = - Arrangement.spacedBy(20.dp)` for `GlimmerLazyColumn` to ensure visual separation between items on the glasses display. -- Be sure to use the default 20 dp spacing between list items unless otherwise specified. - -##### Technical Documentation Links - -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 (GlimmerLazyListState):** Use [references/liststate-source.md](references/liststate-source.md). +Use `GlimmerLazyColumn` (`androidx.xr.glimmer.list.GlimmerLazyColumn`) for +homogeneous scrollable items. When building scrollable lists and list items, +follow the API definitions in [list-source.md](references/list-source.md), [listitem-source.md](references/listitem-source.md), +and [liststate-source.md](references/liststate-source.md). + +- **Always use `GlimmerLazyColumn`** : Never use foundation `LazyColumn`. Import `GlimmerLazyColumn`, `rememberGlimmerLazyListState`, and `items` from `androidx.xr.glimmer.list` (`item` is a scope member, not imported). For programmatic scrolling to the first item, hoist `val listState = + rememberGlimmerLazyListState()` and call `listState.scrollToItem(0)` or `listState.animateScrollToItem(0)`. +- **Integrated title, spacing, and rows** : Pass headers using `title = { + TitleChip { Text("...") } }` directly on `GlimmerLazyColumn` (not outside + it), set `verticalArrangement = Arrangement.spacedBy(20.dp)`, and render + rows with `androidx.xr.glimmer.ListItem(onClick = { ... }, supportingLabel = + { Text(...) }) { Text(...) }` (pass the primary label as the trailing + `content` lambda, not `headlineContent`, and never nest a `Card` inside + `ListItem`): + + + ```kotlin + @Composable + fun GlimmerLazyColumnWithTitleChipSample() { + val ingredientItems = + listOf("Milk", "Flour", "Egg", "Salt", "Apples", "Butter", "Vanilla", "Sugar", "Cinnamon") + GlimmerLazyColumn(title = { TitleChip { Text("Ingredients") } }) { + items(ingredientItems) { text -> ListItem { Text(text) } } + } + } + ``` + +
#### Stacks -A stack is a collapsed list that only displays one piece of content at a time, -in a stacked visual, such as a card. If it is useful to show more than one item -at a time, use the Glimmer List control. Don't use a title chip with a stack. -If the items are of different types use a stack to show them. If the items are -of the same type, use a list. - -##### Guidelines and usage - -**Layout tips** - -- Stacks accept variable height items. -- Align your stack control with the bottom of the display. -- The stack control must be 66 dp taller than the tallest item in the stack, allowing room for the scrim and minimizing movement when navigating between items. -- **Clipping and Layering:** To ensure that items behind the top item are correctly clipped and hidden, you **must** apply the `.itemDecoration(CardDefaults.shape)` modifier to the `Card` (or relevant container) inside every `item` block. -- **Depth Shadows:** To maintain spatial separation and element priority, utilize Glimmer's `ShadowScope`, `DepthEffect`, or prescribed `DepthEffectLevels` for depth plane scaling. - -##### Technical Documentation Links - -If you are creating a Glimmer Stack component, read the: - -- **API Source Code (Stack):** Use [references/stack-source.md](references/stack-source.md). -- **API Source Code (StackState):** Use [references/stackstate-source.md](references/stackstate-source.md). -- **API Source Code (StackItemScope):** Use [references/stackitemscope-source.md](references/stackitemscope-source.md). - -#### Text - -In Jetpack Compose Glimmer, the [`Text`](references/android/develop/xr/jetpack-xr-sdk/jetpack-compose-glimmer/text.md) component builds off the Compose -text component, and lets you set various text properties. Be sure to choose a -style from the `GlimmerTheme` for your text. Modify the theme for your -application if you want custom typography. - -##### Essential Constraint: Glimmer Text versus Material Text - -On transparent Display Glasses (additive displays), standard Material `Text` -resolves to dark foreground tokens which render as transparent and invisible. -Glimmer `Text` intelligently manages theme color matching. When no manual color -override is specified, Glimmer `Text` automatically defaults to the content -color provided by the nearest Glimmer surface. - -- **Don't:** Use Material Text -- **Do:** Use Glimmer Text +Use `VerticalStack` (`androidx.xr.glimmer.stack.VerticalStack`) to display +heterogeneous cards one at a time (such as calendar, transit, and weather +cards). **Never** use a `TitleChip` with `VerticalStack`. When implementing a +`VerticalStack`, follow the API definitions in [stack-source.md](references/stack-source.md), +[stackstate-source.md](references/stackstate-source.md), and [stackitemscope-source.md](references/stackitemscope-source.md). + +- **Bottom alignment, height (+66.dp), and `itemDecoration`** : Align + `VerticalStack` to `Alignment.BottomCenter`, set its height to **`66.dp` + taller than the tallest card** (`maxCardHeight + 66.dp`), and apply + `Modifier.itemDecoration(CardDefaults.shape)` to the `Card` inside every + `item { ... }` block so background cards are clipped and masked: + + + ```kotlin + @Composable + fun VerticalStackSample() { + VerticalStack { + item { + Card { Text("Top Card") } + } + item { + Card { Text("Second Card") } + } + item { + Card { Text("Third Card") } + } + } + } + ``` + +
+ +#### Horizontal pagers and animated text + +Use `GlimmerHorizontalPager` +(`androidx.xr.glimmer.pager.GlimmerHorizontalPager`) with +`rememberGlimmerPagerState(pageCount = { ... })` for horizontal card +walkthroughs (never foundation `HorizontalPager`). + +- **Smooth text motion** : Inside pager pages, configure Glimmer [`Text`](references/android/develop/xr/jetpack-xr-sdk/jetpack-compose-glimmer/text.md) + (`androidx.xr.glimmer.Text`, never `androidx.compose.material3.Text`) with + `textMotion = TextMotion.Animated` (using `.copy(textMotion = + TextMotion.Animated)`) for smooth rendering during snap transitions: + + + ```kotlin + // Hoist the pager state, specifying the total page count with a lambda. + val pagerState = rememberGlimmerPagerState(pageCount = { 10 }) + + GlimmerHorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + ) { page -> + // Use Glimmer components like Card and Text for optimized glasses styling. + Card(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Page: $page", + // Recommended: use TextMotion.Animated for smooth transitions in a pager. + style = LocalTextStyle.current.copy(textMotion = TextMotion.Animated), + ) + } + } + ``` + +
#### Surface -`Surface` is a fundamental building block in Glimmer. Use -[references/surface-source.md](references/surface-source.md) for reference. - -A surface represents a distinct visual area or 'physical' boundary for -components such as buttons and cards. Use it if you need to build a custom -component. +`Modifier.surface` (`androidx.xr.glimmer.surface`) builds custom interactive +components (such as status badges without `Card` or `Button`). When creating +custom surfaces, follow the API definitions in [surface-source.md](references/surface-source.md). + +- **Shared `MutableInteractionSource` and order** : Create a single `remember { + MutableInteractionSource() }` and pass it to both + `Modifier.surface(interactionSource = interactionSource, ...)` and + `.clickable(interactionSource = interactionSource, ...)` (or `.focusable`), + chaining `.surface(...)` **before** `.clickable(...)`: + + + ```kotlin + @Composable + fun FocusableSurfaceSample() { + val interactionSource = remember { MutableInteractionSource() } + Box( + modifier = Modifier + .size(100.dp) + .surface(interactionSource = interactionSource) + .focusable(interactionSource = interactionSource), + contentAlignment = Alignment.Center + ) { + Text("Focusable") + } + } + + @Composable + fun ClickableSurfaceSample() { + val interactionSource = remember { MutableInteractionSource() } + Box( + modifier = Modifier + .size(100.dp) + .surface(interactionSource = interactionSource) + .focusable(interactionSource = interactionSource) + .clickable( + interactionSource = interactionSource, + indication = null + ) { /* Handle click action */ }, + contentAlignment = Alignment.Center + ) { + Text("Clickable") + } + } + ``` + +
+ +#### Voice input indicator and indirect pointer gestures + +- **`VoiceInputIndicator` and `ContainedVoiceInputIndicator`** : Pass a `level` + lambda returning a `Float` normalized to `0.0f..1.0f`. When driven by + `SpeechRecognizer` (`RecognitionListener.onRmsChanged(rmsdB: Float)`), + normalize and clamp `rmsdB` with `.coerceIn(0f, 1f)`: + + + ```kotlin + // Example state variable to hold the normalized level (0.0 to 1.0) + // that the VoiceInputIndicator component expects. + val audioLevel = MutableStateFlow(0f) + + // Initialize the Android SpeechRecognizer + val speechRecognizer = SpeechRecognizer.createSpeechRecognizer(context) + + // Listener to capture speech events and audio level changes + val listener = object : RecognitionListener { + override fun onRmsChanged(rmsdB: Float) { + // Normalize raw dB level to a 0.0-1.0 range. + // Android SpeechRecognizer's rmsdB typically ranges from 0 to ~10. + audioLevel.value = ((rmsdB - 1f) / 9f).coerceIn(0f, 1f) + } + + // ... Implement other required RecognitionListener methods ... + override fun onReadyForSpeech(params: Bundle?) {} + override fun onBeginningOfSpeech() {} + override fun onEndOfSpeech() {} + override fun onError(error: Int) {} + override fun onResults(results: Bundle?) {} + override fun onPartialResults(partialResults: Bundle?) {} + override fun onEvent(eventType: Int, params: Bundle?) {} + override fun onBufferReceived(buffer: ByteArray?) {} + } + + // Attach the listener to the recognizer + speechRecognizer.setRecognitionListener(listener) + + // Create an intent to specify the recognition model and behavior + val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + } + // Begin listening for audio input + speechRecognizer.startListening(intent) + ``` + +
+ + + ```kotlin + @Composable + fun VoiceInputExample(modifier: Modifier) { + // Collect the flow as Compose State so the UI reacts to changes in real-time. + val currentLevel by audioLevel.collectAsState() + + VoiceInputIndicator( + // The VoiceInputIndicator component provides a visual "pulse" or indicator + // that changes based on the 'level' lambda, which returns a Float between 0.0 and 1.0. + level = { currentLevel }, + modifier = modifier + ) + } + ``` + +
+ +- **`Modifier.onIndirectPointerGesture`** : Handle low-level touchpad tap + (`onClick`), forward swipe (`onSwipeForward`), and backward swipe + (`onSwipeBackward`) gestures using + `androidx.xr.glimmer.onIndirectPointerGesture` on a container made + **focusable** (using `focusTarget`, `focusable`, or `surface(focusable = + true)`): + + + ```kotlin + @Composable + fun OnIndirectPointerGestureSample() { + var lastGesture by remember { mutableStateOf("None") } + + Box( + modifier = + Modifier.fillMaxSize() + .onIndirectPointerGesture( + enabled = true, + onSwipeForward = { lastGesture = "Forward" }, + onSwipeBackward = { lastGesture = "Backward" }, + onClick = { lastGesture = "Click" }, + ) + .focusTarget(), + contentAlignment = Alignment.Center, + ) { + Text("Last Gesture: $lastGesture") + } + } + ``` + +
## 5. Integrate with system UI -- For a detailed breakdown of notifications on intelligent eyewear, see [Understand notification behavior for intelligent eyewear](https://developer.android.com/develop/xr/jetpack-xr-sdk/ai-glasses/notifications/behavior) and learn how to [Start a glasses activity on display glasses from a notification](https://developer.android.com/develop/xr/jetpack-xr-sdk/ai-glasses/notifications/start-activity). +- When handling notifications or starting a projected activity from a notification, follow the instructions in [Understand notification behavior + for intelligent eyewear](https://developer.android.com/develop/xr/jetpack-xr-sdk/ai-glasses/notifications/behavior) and [Start a glasses activity on display + glasses from a notification](https://developer.android.com/develop/xr/jetpack-xr-sdk/ai-glasses/notifications/start-activity). diff --git a/.agents/skills/display-glasses-with-jetpack-compose-glimmer/references/button-source.md b/.agents/skills/display-glasses-with-jetpack-compose-glimmer/references/button-source.md index 24fe20c..b8e116b 100644 --- a/.agents/skills/display-glasses-with-jetpack-compose-glimmer/references/button-source.md +++ b/.agents/skills/display-glasses-with-jetpack-compose-glimmer/references/button-source.md @@ -203,7 +203,7 @@ public object ButtonDefaults { */ @Composable public fun focusedColor(baseColor: Color = GlimmerTheme.colors.surface): Color = - SurfaceDefaults.focusedColor(baseColor) + SurfaceDefaults.calculateFocusedColor(baseColor) /** Default minimum height for [Button] and [ToggleButton] with the specified [buttonSize]. */ internal fun minimumHeight(buttonSize: ButtonSize): Dp { diff --git a/.agents/skills/display-glasses-with-jetpack-compose-glimmer/references/projectedcontext-source.md b/.agents/skills/display-glasses-with-jetpack-compose-glimmer/references/projectedcontext-source.md index 29be855..a587e29 100644 --- a/.agents/skills/display-glasses-with-jetpack-compose-glimmer/references/projectedcontext-source.md +++ b/.agents/skills/display-glasses-with-jetpack-compose-glimmer/references/projectedcontext-source.md @@ -246,13 +246,25 @@ public object ProjectedContext { } @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) - private fun getProjectedDeviceId(context: Context) = - context - .getSystemService(VirtualDeviceManager::class.java) - .virtualDevices - // TODO: b/424824481 - Replace the name matching with a better method. - .find { it.name?.startsWith(PROJECTED_DEVICE_NAME) ?: false } - ?.deviceId + private fun getProjectedDeviceId(context: Context): Int? { + val projectedDevices = + context + .getSystemService(VirtualDeviceManager::class.java) + .virtualDevices + // TODO: b/424824481 - Replace the name matching with a better method. + .filter { it.name?.startsWith(PROJECTED_DEVICE_NAME) ?: false } + // Prefer a device with active virtual displays so a stale device without displays from an + // earlier incomplete session is not selected over an active one. + val candidates = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + projectedDevices.filter { it.displayIds.isNotEmpty() }.ifEmpty { projectedDevices } + } else { + projectedDevices + } + // VirtualDeviceManager assigns monotonically increasing device IDs on creation, so picking + // the largest deviceId selects the most recently created device. + return candidates.maxByOrNull { it.deviceId }?.deviceId + } @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) private fun getVirtualDevice(context: Context) = diff --git a/.agents/skills/display-glasses-with-jetpack-compose-glimmer/references/surface-source.md b/.agents/skills/display-glasses-with-jetpack-compose-glimmer/references/surface-source.md index f54498f..34f0045 100644 --- a/.agents/skills/display-glasses-with-jetpack-compose-glimmer/references/surface-source.md +++ b/.agents/skills/display-glasses-with-jetpack-compose-glimmer/references/surface-source.md @@ -26,12 +26,7 @@ import android.graphics.RuntimeShader import android.os.Build import androidx.annotation.RequiresApi import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.AnimationVector1D -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.LinearOutSlowInEasing -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween import androidx.compose.foundation.interaction.FocusInteraction import androidx.compose.foundation.interaction.Interaction import androidx.compose.foundation.interaction.InteractionSource @@ -46,7 +41,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Outline @@ -70,16 +64,27 @@ import androidx.compose.ui.platform.InspectorInfo import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp +import androidx.xr.glimmer.internal.BlurShader +import androidx.xr.glimmer.internal.BorderShader +import androidx.xr.glimmer.internal.SurfaceAnimationAmbientDelayMillis +import androidx.xr.glimmer.internal.SurfaceAnimationAmbientEnterExit +import androidx.xr.glimmer.internal.SurfaceAnimationAmbientInitialDelayMillis +import androidx.xr.glimmer.internal.SurfaceAnimationFocusedEnter +import androidx.xr.glimmer.internal.SurfaceAnimationFocusedExit +import androidx.xr.glimmer.internal.SurfaceAnimationPressedEnter +import androidx.xr.glimmer.internal.SurfaceAnimationPressedExit +import androidx.xr.glimmer.internal.SurfaceAnimationPressedMinimumDurationMillis +import androidx.xr.glimmer.internal.SurfaceDisabledOverlayColor +import androidx.xr.glimmer.internal.SurfaceFocusedColorTone import androidx.xr.glimmer.internal.color.withTone +import androidx.xr.glimmer.internal.surfacePressedOverlayColor import kotlin.math.ceil import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import org.intellij.lang.annotations.Language /** * A surface is a fundamental building block in Glimmer. A surface represents a distinct visual area @@ -130,7 +135,7 @@ import org.intellij.lang.annotations.Language * suitable for a surface background. * @param focusedColor the background [Color] for this surface when focused. When providing a custom * color, ensure it is suitable for a focused surface background or use - * [SurfaceDefaults.focusedColor] to adapt it. + * [SurfaceDefaults.calculateFocusedColor] to adapt it. * @param contentColor the [Color] for content inside this surface * @param focusedContentColor the [Color] for content inside this surface when focused * @param depthEffect the [SurfaceDepthEffect] for this surface, representing the [DepthEffect] @@ -145,7 +150,7 @@ public fun Modifier.surface( enabled: Boolean = true, shape: Shape = GlimmerTheme.shapes.medium, color: Color = GlimmerTheme.colors.surface, - focusedColor: Color = SurfaceDefaults.focusedColor(color), + focusedColor: Color = SurfaceDefaults.calculateFocusedColor(color), contentColor: Color = calculateContentColor(color), focusedContentColor: Color = calculateContentColor(focusedColor), depthEffect: SurfaceDepthEffect? = null, @@ -167,19 +172,15 @@ public fun Modifier.surface( /** Contains default values used by [surface]. */ public object SurfaceDefaults { - /** - * Returns the focused background [Color] for a surface derived from the provided [baseColor]. - * - * Adjusts the provided [baseColor] so that it is suitable for use as a focused surface - * background. + * Derives a color suitable for use as a focused background color from the provided [seedColor]. * - * @param baseColor the base [Color] of the surface - * @return the focused surface background [Color], adjusted to improve content contrast + * @param seedColor the base [Color] from which the focused surface color will be derived + * @return a color to be used as the focused surface background [Color], adjusted to improve + * content contrast */ - @Composable - public fun focusedColor(baseColor: Color = GlimmerTheme.colors.surface): Color = - baseColor.withTone(newTone = FocusedSurfaceColorTone) + public fun calculateFocusedColor(seedColor: Color): Color = + seedColor.withTone(newTone = SurfaceFocusedColorTone) } /** @@ -430,17 +431,17 @@ private class SurfaceNode( minimumPressDuration = coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) { - delay(PressedMinimumDurationMillis) + delay(SurfaceAnimationPressedMinimumDurationMillis) } coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) { - _pressedProgress?.animateTo(1f, PressedEnterAnimationSpec) + _pressedProgress?.animateTo(1f, SurfaceAnimationPressedEnter) } } else { _pressedProgress?.let { progress -> pressReleaseAnimation = coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) { minimumPressDuration?.join() - progress.animateTo(0f, PressedExitAnimationSpec) + progress.animateTo(0f, SurfaceAnimationPressedExit) } } } @@ -478,21 +479,24 @@ private class SurfaceNode( _ambientProgress = _ambientProgress ?: Animatable(0f) coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) { - _focusProgress?.animateTo(targetValue = 1f, animationSpec = FocusedEnterAnimationSpec) + _focusProgress?.animateTo( + targetValue = 1f, + animationSpec = SurfaceAnimationFocusedEnter, + ) } ambientMotionJob?.cancel() ambientMotionJob = coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) { // Delay after focus enter starts - delay(AmbientInitialDelayMillis) + delay(SurfaceAnimationAmbientInitialDelayMillis) while (isActive) { _ambientProgress?.animateTo( targetValue = 1f, - animationSpec = AmbientAnimationSpec, + animationSpec = SurfaceAnimationAmbientEnterExit, ) _ambientProgress?.snapTo(0f) - delay(AmbientDelayMillis) + delay(SurfaceAnimationAmbientDelayMillis) } } } @@ -502,11 +506,17 @@ private class SurfaceNode( ambientMotionJob = null coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) { - _ambientProgress?.animateTo(targetValue = 0f, animationSpec = FocusedExitAnimationSpec) + _ambientProgress?.animateTo( + targetValue = 0f, + animationSpec = SurfaceAnimationFocusedExit, + ) } coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) { - _focusProgress?.animateTo(targetValue = 0f, animationSpec = FocusedExitAnimationSpec) + _focusProgress?.animateTo( + targetValue = 0f, + animationSpec = SurfaceAnimationFocusedExit, + ) } } @@ -527,7 +537,7 @@ private class SurfaceNode( drawContent() if (!enabled) { - drawOutline(outline, color = DisabledOverlayColor) + drawOutline(outline, color = SurfaceDisabledOverlayColor) } } @@ -545,9 +555,7 @@ private class SurfaceNode( // Pressed overlay transition: #FFFFFF with 16% opacity when pressed val compositeBackground = if (pressedProgress > 0f) { - val pressedOverlayColor = - PressedOverlayColor.copy(alpha = PressedOverlayAlpha * pressedProgress) - pressedOverlayColor.compositeOver(backgroundColor) + surfacePressedOverlayColor(pressedProgress).compositeOver(backgroundColor) } else { backgroundColor } @@ -564,7 +572,8 @@ private class SurfaceNode( } } .also { borderLayerProvider = it } - val borderColor = lerp(DefaultSolidBorderIdleColor, focusedBorderColor1, focusProgress) + val borderColor = + lerp(BorderShader.DefaultSolidBorderIdleColor, focusedBorderColor1, focusProgress) borderLogic.drawBorder( this, @@ -577,7 +586,11 @@ private class SurfaceNode( /** Calculates solid border width for devices below API 33 ([Build.VERSION_CODES.TIRAMISU]). */ private fun DrawScope.calculateSolidBorderWidth(): Dp = - lerp(DefaultSurfaceBorderWidth, FocusedSurfaceBorderWidth, this@SurfaceNode.focusProgress) + lerp( + BorderShader.DefaultSurfaceBorderWidth, + BorderShader.FocusedSurfaceBorderWidth, + this@SurfaceNode.focusProgress, + ) /** * Calculates AGSL shader border width for API 33 ([Build.VERSION_CODES.TIRAMISU]) and higher. @@ -585,8 +598,8 @@ private class SurfaceNode( private fun DrawScope.calculateBorderWidth(): Dp = lerp( lerp( - DefaultSurfaceBorderWidth, - FocusedSurfaceBorderWidth, + BorderShader.DefaultSurfaceBorderWidth, + BorderShader.FocusedSurfaceBorderWidth, this@SurfaceNode.focusProgress, ), (size.minDimension / 8f).toDp(), @@ -607,12 +620,12 @@ private class SurfaceNode( val width = size.width val height = size.height - val idleBlurRadiusStartPx = IdleBlurRadiusStart.toPx() - val idleBlurRadiusEndPx = IdleBlurRadiusEnd.toPx() - val focusedBlurRadiusStartPx = FocusedBlurRadiusStart.toPx() - val focusedBlurRadiusEndPx = FocusedBlurRadiusEnd.toPx() - val ambientBlurRadiusMaxStartPx = AmbientBlurRadiusMaxStart.toPx() - val ambientBlurRadiusMaxEndPx = AmbientBlurRadiusMaxEnd.toPx() + val idleBlurRadiusStartPx = BlurShader.IdleBlurRadiusStart.toPx() + val idleBlurRadiusEndPx = BlurShader.IdleBlurRadiusEnd.toPx() + val focusedBlurRadiusStartPx = BlurShader.FocusedBlurRadiusStart.toPx() + val focusedBlurRadiusEndPx = BlurShader.FocusedBlurRadiusEnd.toPx() + val ambientBlurRadiusMaxStartPx = BlurShader.AmbientBlurRadiusMaxStart.toPx() + val ambientBlurRadiusMaxEndPx = BlurShader.AmbientBlurRadiusMaxEnd.toPx() if (outline is Outline.Rounded) { val roundRect = outline.roundRect @@ -628,7 +641,7 @@ private class SurfaceNode( } val horizontalShader = - BlurShaderHelper.configureShader( + BlurShader.configureShader( shader = this@SurfaceNode.horizontalShader, isVertical = false, width = width, @@ -647,7 +660,7 @@ private class SurfaceNode( .also { this@SurfaceNode.horizontalShader = it } val verticalShader = - BlurShaderHelper.configureShader( + BlurShader.configureShader( shader = this@SurfaceNode.verticalShader, isVertical = true, width = width, @@ -666,8 +679,9 @@ private class SurfaceNode( .also { this@SurfaceNode.verticalShader = it } val horizontalEffect = - RenderEffect.createRuntimeShaderEffect(horizontalShader, UniformContent) - val verticalEffect = RenderEffect.createRuntimeShaderEffect(verticalShader, UniformContent) + RenderEffect.createRuntimeShaderEffect(horizontalShader, BlurShader.UniformContent) + val verticalEffect = + RenderEffect.createRuntimeShaderEffect(verticalShader, BlurShader.UniformContent) val renderEffect = RenderEffect.createChainEffect(verticalEffect, horizontalEffect) val blurLayer = @@ -697,7 +711,7 @@ private class SurfaceNode( drawOutline(outline, backgroundColor) borderShader = - BorderShaderHelper.configureShader( + BorderShader.configureShader( shader = borderShader, size = size, focusProgress = focusProgress, @@ -785,12 +799,12 @@ private fun Modifier.surfaceDepthEffect( when (interaction) { is FocusInteraction.Focus -> launch(start = CoroutineStart.UNDISPATCHED) { - focusedProgress.animateTo(1f, FocusedEnterAnimationSpec) + focusedProgress.animateTo(1f, SurfaceAnimationFocusedEnter) } is FocusInteraction.Unfocus -> launch(start = CoroutineStart.UNDISPATCHED) { - focusedProgress.animateTo(0f, FocusedExitAnimationSpec) + focusedProgress.animateTo(0f, SurfaceAnimationFocusedExit) } } } @@ -808,394 +822,6 @@ private fun Modifier.surfaceDepthEffect( progress = { focusedProgress.value }, ) } - -/** String keys map directly to uniform parameter names inside the AGSL runtime shaders. */ -private const val UniformResolution = "uResolution" -private const val UniformCornerRadii = "uCornerRadii" -private const val UniformFocusProgress = "uFocusProgress" -private const val UniformAmbientProgress = "uAmbientProgress" -private const val UniformPressedProgress = "uPressedProgress" -private const val UniformIdleStartRadius = "uIdleRadiusStart" -private const val UniformIdleEndRadius = "uIdleRadiusEnd" -private const val UniformFocusedStartRadius = "uFocusedRadiusStart" -private const val UniformFocusedEndRadius = "uFocusedRadiusEnd" -private const val UniformAmbientMaxStartRadius = "uAmbientMaxRadiusStart" -private const val UniformAmbientMaxEndRadius = "uAmbientMaxRadiusEnd" -private const val UniformContent = "uContent" - -private const val UniformBorderFocusedColor0 = "uBorderFocusedColor0" -private const val UniformBorderFocusedColor1 = "uBorderFocusedColor1" -private const val UniformBorderFocusedColor2 = "uBorderFocusedColor2" -private const val UniformBorderFocusedColor3 = "uBorderFocusedColor3" - -private const val SurfaceColorTone = 20f -private const val FocusedSurfaceColorTone = 34f - -/** Default border width for a [surface]. */ -private val DefaultSurfaceBorderWidth = 1.5.dp - -/** Focused border width for a [surface]. */ -private val FocusedSurfaceBorderWidth = 2.dp - -/** Blur radii at the start / end of the blur gradient while the surface is idle (unfocused). */ -private val IdleBlurRadiusStart = 2.dp -private val IdleBlurRadiusEnd = 8.dp - -/** Blur radii at the start / end of the blur gradient while the surface is focused. */ -private val FocusedBlurRadiusStart = 1.dp -private val FocusedBlurRadiusEnd = 3.dp - -/** Maximum blur radii reached at the peak of the ambient motion */ -private val AmbientBlurRadiusMaxStart = 5.3.dp -private val AmbientBlurRadiusMaxEnd = 15.9.dp - -private val DefaultSolidBorderIdleColor = Color(0.25f, 0.25f, 0.25f, 0.5f) - -/** Enter animation for focus highlight and depth effect */ -private val FocusedEnterAnimationSpec: AnimationSpec = - tween(durationMillis = 800, easing = LinearOutSlowInEasing) - -/** Exit animation for focus highlight and depth effect */ -private val FocusedExitAnimationSpec: AnimationSpec = - tween(durationMillis = 500, easing = LinearOutSlowInEasing) - -private val AmbientAnimationSpec: AnimationSpec = - tween(durationMillis = 2000, easing = LinearEasing) - -private const val AmbientInitialDelayMillis = 1800L -private const val AmbientDelayMillis = 4000L - -internal val DisabledOverlayColor = Color(0x8F191919) - -private val PressedOverlayColor = Color.White -private const val PressedOverlayAlpha = 0.16f - -private val PressedEnterAnimationSpec: AnimationSpec = - spring(dampingRatio = 0.84f, stiffness = 8000f) - -private val PressedExitAnimationSpec: AnimationSpec = - spring(dampingRatio = 0.85f, stiffness = 50f) - -private const val PressedMinimumDurationMillis = 300L - -@Language(value = "AGSL") -private const val BorderShader = - """ -uniform float2 uResolution; -uniform float uFocusProgress; -uniform float uAmbientProgress; - -const half4 uBorderIdleColor0 = half4(0.81, 0.81, 0.81, 0.9); // Top-Left -const half4 uBorderIdleColor1 = half4(0.25, 0.25, 0.25, 0.5); // Top-Right -const half4 uBorderIdleColor2 = half4(0.16, 0.16, 0.16, 0.4); // Bottom-Right -const half4 uBorderIdleColor3 = half4(0.49, 0.49, 0.49, 0.7); // Bottom-Left - -uniform half4 uBorderFocusedColor0; -uniform half4 uBorderFocusedColor1; -uniform half4 uBorderFocusedColor2; -uniform half4 uBorderFocusedColor3; - -const half4 uAnimationPeakColor = half4(1.0, 1.0, 1.0, 1.0); // White - -const float PI = 3.14159265; - -half4 main(float2 fragCoord) { - // Compute ambient pulse - // Phase 1: Increase Ambient Pulse - // Linearly scales 0.0 -> 0.2835 onto a 0.0 -> 1.0 range. - float t1 = clamp(uAmbientProgress / 0.2835, 0.0, 1.0); - - // Phase 2: Steady and then Ramp-Down - // Delays the fade-out until progress passes 0.375 - // Uses the remaining 0.625 of the duration to smoothly decline back to 0.0. - float t2 = clamp(1.0 - (uAmbientProgress - 0.375) / 0.625, 0.0, 1.0); - - // Uses t1 (ramp-up/hold) for the first 37.5%, then switches to t2 (ramp-down). - float ambientPulse = mix(t1, t2, step(0.375, uAmbientProgress)); - - // Compute focus pulse peaking animation peak color at exactly 0.5 - float focusPulse = clamp(1.0 - 2.0 * abs(uFocusProgress - 0.5), 0.0, 1.0); - - // Use maximum of focus and ambient pulses - float maxAnimationPeakPulse = max(ambientPulse, focusPulse); - - half4 colors[4]; - colors[0] = mix(mix(uBorderIdleColor0, uBorderFocusedColor0, uFocusProgress), uAnimationPeakColor, maxAnimationPeakPulse); - colors[1] = mix(mix(uBorderIdleColor1, uBorderFocusedColor1, uFocusProgress), uAnimationPeakColor, maxAnimationPeakPulse); - colors[2] = mix(mix(uBorderIdleColor2, uBorderFocusedColor2, uFocusProgress), uAnimationPeakColor, maxAnimationPeakPulse); - colors[3] = mix(mix(uBorderIdleColor3, uBorderFocusedColor3, uFocusProgress), uAnimationPeakColor, maxAnimationPeakPulse); - - float w = uResolution.x; - float h = uResolution.y; - - // Normalize coordinates to [-1, 1] relative to center - float nx = 2.0 * fragCoord.x / max(w, 1.0) - 1.0; - float ny = 2.0 * fragCoord.y / max(h, 1.0) - 1.0; - - // Compute the angle of the current pixel relative to the center in radians [-PI, PI]. - float theta = atan(ny, nx); - - // Map the angle from [-PI, PI] to a clean [0.0, 1.0] percentage circle. - // The "+ 0.75 * PI" offsets the starting point (0.0) to the Top-Left corner. - float d_cw = fract((theta + 0.75 * PI) / (2.0 * PI)); - - // As focus progresses from 0 to 1, the gradient start position shifts from top left to top right - float d_start = mix(0.0, 0.25, uFocusProgress); - - // Apply the rotation shift to the current pixel's position. - float d_rotated = mod(d_cw - d_start + 1.0, 1.0); - float dist = min(d_rotated, 1.0 - d_rotated); - - // Branchless color interpolation using chained mixes - float factor0 = smoothstep(0.0, 0.1, dist); - float factor1 = smoothstep(0.1, 0.25, dist); - float factor2 = smoothstep(0.25, 0.4, dist); - - half4 color = mix(colors[0], colors[1], factor0); - color = mix(color, colors[2], factor1); - color = mix(color, colors[3], factor2); - - return color; -} -""" - -@RequiresApi(Build.VERSION_CODES.TIRAMISU) -private object BlurShaderHelper { - @JvmStatic - fun configureShader( - shader: RuntimeShader?, - isVertical: Boolean, - width: Float, - height: Float, - cornerRadii: FloatArray, - focusProgress: Float, - ambientProgress: Float, - pressedProgress: Float, - idleBlurRadiusStartPx: Float, - idleBlurRadiusEndPx: Float, - focusedBlurRadiusStartPx: Float, - focusedBlurRadiusEndPx: Float, - ambientBlurRadiusMaxStartPx: Float, - ambientBlurRadiusMaxEndPx: Float, - ): RuntimeShader { - val runtimeShader = shader ?: RuntimeShader(getBlurShader(isVertical)) - runtimeShader.setFloatUniform(UniformResolution, width, height) - runtimeShader.setFloatUniform(UniformCornerRadii, cornerRadii) - runtimeShader.setFloatUniform(UniformFocusProgress, focusProgress) - runtimeShader.setFloatUniform(UniformAmbientProgress, ambientProgress) - runtimeShader.setFloatUniform(UniformPressedProgress, pressedProgress) - runtimeShader.setFloatUniform(UniformIdleStartRadius, idleBlurRadiusStartPx) - runtimeShader.setFloatUniform(UniformIdleEndRadius, idleBlurRadiusEndPx) - runtimeShader.setFloatUniform(UniformFocusedStartRadius, focusedBlurRadiusStartPx) - runtimeShader.setFloatUniform(UniformFocusedEndRadius, focusedBlurRadiusEndPx) - runtimeShader.setFloatUniform(UniformAmbientMaxStartRadius, ambientBlurRadiusMaxStartPx) - runtimeShader.setFloatUniform(UniformAmbientMaxEndRadius, ambientBlurRadiusMaxEndPx) - return runtimeShader - } -} - -@RequiresApi(Build.VERSION_CODES.TIRAMISU) -private object BorderShaderHelper { - @JvmStatic - fun configureShader( - shader: RuntimeShader?, - size: Size, - focusProgress: Float, - ambientProgress: Float, - focusedBorderColor0: Color, - focusedBorderColor1: Color, - focusedBorderColor2: Color, - focusedBorderColor3: Color, - ): RuntimeShader { - val runtimeShader = shader ?: RuntimeShader(BorderShader) - runtimeShader.setFloatUniform(UniformResolution, size.width, size.height) - runtimeShader.setFloatUniform(UniformFocusProgress, focusProgress) - runtimeShader.setFloatUniform(UniformAmbientProgress, ambientProgress) - - runtimeShader.setFloatUniform( - UniformBorderFocusedColor0, - focusedBorderColor0.red, - focusedBorderColor0.green, - focusedBorderColor0.blue, - focusedBorderColor0.alpha, - ) - - runtimeShader.setFloatUniform( - UniformBorderFocusedColor1, - focusedBorderColor1.red, - focusedBorderColor1.green, - focusedBorderColor1.blue, - focusedBorderColor1.alpha, - ) - - runtimeShader.setFloatUniform( - UniformBorderFocusedColor2, - focusedBorderColor2.red, - focusedBorderColor2.green, - focusedBorderColor2.blue, - focusedBorderColor2.alpha, - ) - - runtimeShader.setFloatUniform( - UniformBorderFocusedColor3, - focusedBorderColor3.red, - focusedBorderColor3.green, - focusedBorderColor3.blue, - focusedBorderColor3.alpha, - ) - - return runtimeShader - } -} - -@Language(value = "AGSL") -@RequiresApi(Build.VERSION_CODES.TIRAMISU) -private fun getBlurShader(isVertical: Boolean): String { - val loopOffset = - if (isVertical) "float2(0.0, i + weightH / weight)" else "float2(i + weightH / weight, 0.0)" - val oddOffset = if (isVertical) "float2(0.0, r)" else "float2(r, 0.0)" - return """ - uniform shader uContent; - - uniform float2 uResolution; - uniform float4 uCornerRadii; - uniform float uFocusProgress; - uniform float uAmbientProgress; - uniform float uPressedProgress; - - uniform float uIdleRadiusStart; - uniform float uIdleRadiusEnd; - uniform float uFocusedRadiusStart; - uniform float uFocusedRadiusEnd; - uniform float uAmbientMaxRadiusStart; - uniform float uAmbientMaxRadiusEnd; - - const float MAX_RADIUS = 120.0; - const float PI = 3.14159265; - - float sdRoundedBox(float2 p, float2 b, float4 r) { - float2 sign_p = step(0.0, p); - float2 r2 = mix(r.zw, r.xy, sign_p.x); - float rad = mix(r2.y, r2.x, sign_p.y); - - float2 q = abs(p) - b + rad; - return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - rad; - } - - float gaussian(float x, float sigma) { return exp(-(x * x) / (2.0 * sigma * sigma)); } - - float radiusAt(float2 coord, float2 start, float2 end, float r0, float r1) { - float2 axis = end - start; - float t = clamp(dot(coord - start, axis) / max(dot(axis, axis), 1.0), 0.0, 1.0); - return mix(r0, r1, t); - } - - float4 blur(float2 coord, float radius) { - float r = floor(radius); - if (r < 1.0) return uContent.eval(coord); - float sigma = max(radius / 2.0, 1.0); - float2 center = uResolution / 2.0; - float2 halfSize = uResolution / 2.0; - float weightSum = 1.0; - float limit = min(sigma * 3, MAX_RADIUS); - float4 result = uContent.eval(coord); - for (float i = 1.0; i < MAX_RADIUS; i += 2.0) { - if (i >= limit) { break; } - float weightL = gaussian(i, sigma); - float weightH = gaussian(i + 1.0, sigma); - float weight = weightL + weightH; - float2 off = $loopOffset; - - float2 c1 = coord - off; - float d1 = sdRoundedBox(c1 - center, halfSize, uCornerRadii); - float w1 = mix(weight, 0.0, step(0.0, d1)); - if (w1 > 0.0) { - result += w1 * uContent.eval(c1); - weightSum += w1; - } - - float2 c2 = coord + off; - float d2 = sdRoundedBox(c2 - center, halfSize, uCornerRadii); - float w2 = mix(weight, 0.0, step(0.0, d2)); - if (w2 > 0.0) { - result += w2 * uContent.eval(c2); - weightSum += w2; - } - } - float oddMask = fract(r * 0.5) * 2.0 * (1.0 - step(MAX_RADIUS, r)); - float ow = gaussian(r, sigma) * oddMask; - - if (ow > 0.0) { - float2 oo = $oddOffset; - float2 o1 = coord - oo; - float2 o2 = coord + oo; - result += ow * uContent.eval(o1) + ow * uContent.eval(o2); - weightSum += 2.0 * ow; - } - return result / weightSum; - } - - half4 main(float2 coord) { - // Compute ambient pulse - // Phase 1: Increase Ambient Pulse - // Linearly scales 0.0 -> 0.2835 onto a 0.0 -> 1.0 range. - float t1 = clamp(uAmbientProgress / 0.2835, 0.0, 1.0); - - // Phase 2: Steady and then Ramp-Down - // Delays the fade-out until progress passes 0.375 - // Uses the remaining 0.625 of the duration to smoothly decline back to 0.0. - float t2 = clamp(1.0 - (uAmbientProgress - 0.375) / 0.625, 0.0, 1.0); - - // Uses t1 (ramp-up/hold) for the first 37.5%, then switches to t2 (ramp-down). - float pulse = mix(t1, t2, step(0.375, uAmbientProgress)); - - // Mix focused and ambient blurs based on the pulse - float focusedBlurStart = mix(uFocusedRadiusStart, uAmbientMaxRadiusStart, pulse); - float focusedBlurEnd = mix(uFocusedRadiusEnd, uAmbientMaxRadiusEnd, pulse); - - // Interpolate between idle and focused/ambient blur ranges - float blur0 = mix(uIdleRadiusStart, focusedBlurStart, uFocusProgress); - float blur1 = mix(uIdleRadiusEnd, focusedBlurEnd, uFocusProgress); - - // Enforce massive uniform during press interaction - float minDimension = min(uResolution.x, uResolution.y); - float pressedBlur = minDimension / 3.0; - blur0 = mix(blur0, pressedBlur, uPressedProgress); - blur1 = mix(blur1, pressedBlur, uPressedProgress); - - float centerX = uResolution.x / 2.0; - float centerY = uResolution.y / 2.0; - - // Start position of blur shifts across the width based on focus progress - float startX = uFocusProgress * uResolution.x; - float startY = 0.0; - float endX = uResolution.x / 2.0; - float endY = uResolution.y; - - // Anchor coordinates for rotational animation phase - float baseStartX = uResolution.x; - float baseStartY = 0.0; - float baseEndX = uResolution.x / 2.0; - float baseEndY = uResolution.y; - - // Drive a full circle around the center for ambient motion - float angle = uAmbientProgress * PI * 2; - float cosA = cos(angle); - float sinA = sin(angle); - - // Calculate start and end rotation X and Y - float rotStartX = centerX + (baseStartX - centerX) * cosA - (baseStartY - centerY) * sinA; - float rotStartY = centerY + (baseStartX - centerX) * sinA + (baseStartY - centerY) * cosA; - float rotEndX = centerX + (baseEndX - centerX) * cosA - (baseEndY - centerY) * sinA; - float rotEndY = centerY + (baseEndX - centerX) * sinA + (baseEndY - centerY) * cosA; - - float2 finalStart = mix(float2(startX, startY), float2(rotStartX, rotStartY), uFocusProgress); - float2 finalEnd = mix(float2(endX, endY), float2(rotEndX, rotEndY), uFocusProgress); - - return blur(coord, radiusAt(coord, finalStart, finalEnd, blur0, blur1)); - } - """ -} ```
\ No newline at end of file diff --git a/.agents/skills/glance-canonical-layouts/SKILL.md b/.agents/skills/glance-canonical-layouts/SKILL.md index 955c2e7..b861176 100644 --- a/.agents/skills/glance-canonical-layouts/SKILL.md +++ b/.agents/skills/glance-canonical-layouts/SKILL.md @@ -7,7 +7,7 @@ description: Use this skill to create home screen widgets using Jetpack Glance C license: Complete terms in LICENSE.txt metadata: author: Google LLC - last-updated: '2026-10-13' + last-updated: '2026-10-22' keywords: - android - glance diff --git a/.agents/skills/glance-canonical-layouts/references/utils/ImageUtils.kt b/.agents/skills/glance-canonical-layouts/references/utils/ImageUtils.kt index 6e4d6cc..63aa028 100644 --- a/.agents/skills/glance-canonical-layouts/references/utils/ImageUtils.kt +++ b/.agents/skills/glance-canonical-layouts/references/utils/ImageUtils.kt @@ -4,15 +4,21 @@ import android.content.Context import android.content.res.Resources import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.os.Build import android.util.Size +import android.view.WindowManager import kotlin.math.max import kotlin.math.roundToInt import kotlin.math.sqrt object ImageUtils { + // Default 1.5 MB per-image ceiling when callers do not supply a custom maxBytesPerImage. + private const val DEFAULT_MAX_BYTES_PER_IMAGE = 1_500_000 + /** - * Loads the image resource as bitmap by scaling for the requested size. + * Loads the image resource as bitmap by scaling for the requested size and enforcing + * the per-image AppWidget memory ceiling. * https://developer.android.com/topic/performance/graphics/load-bitmap#load-bitmap */ fun decodeSampledBitmapFromResource( @@ -20,13 +26,42 @@ object ImageUtils { resId: Int, requestedWidth: Int, requestedHeight: Int, + preferredConfig: Bitmap.Config = Bitmap.Config.RGB_565, + maxBytesPerImage: Int = DEFAULT_MAX_BYTES_PER_IMAGE, ): Bitmap { - fun calculateInSampleSize(originalSize: Size, requestedSize: Size): Int { + val bytesPerPixel = when (preferredConfig) { + Bitmap.Config.ALPHA_8 -> 1 + Bitmap.Config.RGB_565 -> 2 + Bitmap.Config.RGBA_F16 -> 8 + else -> 4 + } + + fun calculateInSampleSize( + originalSize: Size, + requestedSize: Size, + effectiveBytesPerPixel: Int = bytesPerPixel, + ): Int { val (height: Int, width: Int) = originalSize.height to originalSize.width + val reqHeight = max(1, requestedSize.height) + val reqWidth = max(1, requestedSize.width) var inSampleSize = 1 - // Calculate the largest inSampleSize value that is a power of 2 and keeps both - // height and width larger than the requested height and width. - while (height / inSampleSize > requestedSize.height && width / inSampleSize > requestedSize.width) { + // Stage 1: Downsample to the highest quality the display can render for the viewable size, + // and also halve whenever either dimension exceeds 2x the requested dimension (so wide + // banners or tall portraits do not bypass downsampling when one axis already fits). + while ( + (height / (inSampleSize * 2) >= reqHeight && + width / (inSampleSize * 2) >= reqWidth) || + (height / inSampleSize > reqHeight * 2) || + (width / inSampleSize > reqWidth * 2) + ) { + inSampleSize *= 2 + } + // Stage 2: Enforce the exact per-image OS widget memory ceiling. + while ( + (max(1, width / inSampleSize).toLong() * + max(1, height / inSampleSize).toLong() * + effectiveBytesPerPixel) > maxBytesPerImage + ) { inSampleSize *= 2 } return inSampleSize @@ -35,17 +70,28 @@ object ImageUtils { // First decode with inJustDecodeBounds=true to check dimensions return BitmapFactory.Options().run { inJustDecodeBounds = true + inPreferredConfig = preferredConfig + inScaled = false BitmapFactory.decodeResource(res, resId, this) val originalWidth: Int = outWidth val originalHeight: Int = outHeight + // If the resource has an alpha channel (e.g. non-opaque PNG/WebP), Android's built-in + // BitmapFactory ignores inPreferredConfig = RGB_565 and sets outConfig = ARGB_8888 (4 B/px). + val effectiveBytesPerPixel = when (outConfig ?: preferredConfig) { + Bitmap.Config.ALPHA_8 -> 1 + Bitmap.Config.RGB_565 -> 2 + Bitmap.Config.RGBA_F16 -> 8 + else -> 4 + } inSampleSize = calculateInSampleSize( originalSize = Size(originalWidth, originalHeight), - requestedSize = Size(requestedWidth, requestedHeight) + requestedSize = Size(requestedWidth, requestedHeight), + effectiveBytesPerPixel = effectiveBytesPerPixel, ) - // Decode bitmap with inSampleSize set + // Decode bitmap with inSampleSize and preferredConfig set inJustDecodeBounds = false BitmapFactory.decodeResource(res, resId, this) @@ -53,13 +99,25 @@ object ImageUtils { } /** - * Returns maximum bytes allowed per the appwidget limits for bitmaps in the remote views. + * Estimates the maximum bytes allowed per the appwidget limits for bitmaps in the remote views. + * The system computes 6x the default display's real size once at boot and does not expose it + * in a public API (b/327416107). Calling WindowManager from a non-UI widget Context logs a + * nonfatal StrictMode violation on API 30+, which is expected for this estimate. Always size + * images to the widget's own target dimensions first (Stage 1). */ fun Context.getMaxWidgetMemoryAllowedSizeInBytes(): Int { - val size = resources.displayMetrics.run { Size(widthPixels, heightPixels) } - // Cap memory usage at 1.5 times the size of the display - // 1.5 * 4 bytes/pixel * w * h ==> 6 * w * h - // See https://cs.android.com/android/platform/superproject/+/master:frameworks/base/services/appwidget/java/com/android/server/appwidget/AppWidgetServiceImpl.java;l=274-281;drc=389cb6f54a5a5bb8dea540f57a3a8ac3c3c1c758 + val windowManager = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + getSystemService(WindowManager::class.java) + } else { + null + } + val size = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && windowManager != null) { + val bounds = windowManager.currentWindowMetrics.bounds + Size(bounds.width(), bounds.height()) + } else { + resources.displayMetrics.run { Size(widthPixels, heightPixels) } + } + // System cap is 1.5 times the size of the display at 4 bytes/pixel (6 * width * height). return 6 * size.width * size.height } @@ -68,7 +126,7 @@ object ImageUtils { */ fun getMaxPossibleImageSize(aspectRatio: Double, memoryLimitBytes: Int, maxImages: Int): Size { // for each orientation (landscape, portrait, +2 for fold). - val limit = (memoryLimitBytes / 4) / maxImages + val limit = (memoryLimitBytes / 4) / max(1, maxImages) val maxSizeAllowedPerPixel: Int = limit / BYTES_PER_PIXEL val side = sqrt(maxSizeAllowedPerPixel.toDouble()).toInt() diff --git a/.agents/skills/jetpack-compose-performance/SKILL.md b/.agents/skills/jetpack-compose-performance/SKILL.md index d8ec13b..29a3300 100644 --- a/.agents/skills/jetpack-compose-performance/SKILL.md +++ b/.agents/skills/jetpack-compose-performance/SKILL.md @@ -6,7 +6,7 @@ description: Diagnose and optimize Android Jetpack Compose UI code. Use this ski license: Complete terms in LICENSE.txt metadata: author: Google LLC - last-updated: '2026-10-14' + last-updated: '2026-10-22' keywords: - Jetpack Compose - performance @@ -75,7 +75,7 @@ place: - **When to Leave Untouched** : For all other existing `@Stable`, `@Immutable`, or `ImmutableList` usages in a codebase---such as non-lazy UI models, standalone components, or models with small/bounded collections (e.g., a card with a few badges, thumbnails, or metadata rows) where dataset size is small or unknown---**leave the existing annotations untouched and do not flag them** . Never blindly grep and flag all `@Stable`/`@Immutable` classes across a codebase without verifying they feed a lazy layout with a large dataset. - **When to Add `@Stable` / `@Immutable` Under Strong Skipping Mode** : Flat `data class`es with `val` primitive/`String` properties in Compose modules are **already inferred as stable** by the compiler and use `.equals()` automatically---do not add redundant `@Immutable` annotations to them. Only add `@Stable` or `@Immutable` when the compiler cannot infer stability (e.g. classes from non-Compose modules or interfaces) **and** a data source (such as Room or MVI reducers) allocates new object instances for unchanged items on every emission, **provided** the class is small, flat, has no collections (`List`, `Set`, `Map`), and its `.equals()` check is trivial `O(1)`. -- **Make Composables Cheaper Instead of Forcing Skipping**: Do not overindex on recomposition counts. Skipping one composable during an active recomposition pass saves little time once composition is scheduled (leaving the house is the bulk of the cost). Focus on making composables cheap to execute, deferring state reads to Layout/Draw, and verifying with benchmarks rather than assuming skipping improves performance. +- **Make Composables Cheaper Instead of Forcing Skipping**: Do not overindex on recomposition counts. Skipping one composable during an active recomposition pass saves little time once composition is scheduled (scheduling and starting the recomposition pass is the bulk of the cost). Focus on making composables cheap to execute, deferring state reads to Layout/Draw, and verifying with benchmarks rather than assuming skipping improves performance. ## 2. Layouts and measurement optimization ([references/layouts.md](references/layouts.md)) @@ -127,7 +127,7 @@ place: 1.12`, `SideEffect` takes no arguments, so check the project's Compose version and track key changes with a `remember`ed holder inside `SideEffect { ... }`). - **Cache Allocations in Body** : Wrap heavy object allocations (such as date formatters or complex paths) and expensive computations in `remember`. - **Combine Related `LaunchedEffect`s** : Combine multiple `LaunchedEffect(Unit)` calls into a single `LaunchedEffect` with nested `launch { ... }` blocks to reduce effect node allocations and coroutine launcher overhead. -- **Effect Ordering \& Teardown Costs** : Respect effect execution order (`DisposableEffect` \> `SideEffect` \> Layout and Draw \> `LaunchedEffect` after frame). Note that canceling a `LaunchedEffect` incurs coroutine cancellation and teardown overhead; avoid cycling `LaunchedEffect` keys frequently during animations or scrolling. +- **Effect Ordering \& Teardown Costs** : Understand effect execution order when composition changes are applied (`DisposableEffect` runs first, then `SideEffect`, while `LaunchedEffect` launches a coroutine on the composition's `CoroutineContext` that is dispatched asynchronously and is not guaranteed to wait until after Draw). Note that canceling a `LaunchedEffect` incurs coroutine cancellation and teardown overhead; avoid cycling `LaunchedEffect` keys frequently during animations or scrolling. - **Main Thread Offloading** : Offload blocking I/O, heavy file parsing, and database operations to `withContext(Dispatchers.IO)` using `produceState`. - **No Item-Level Impression Effects in Lazy Layouts** : Never use `LaunchedEffect` or `DisposableEffect` inside lazy items for impression logging. Lazy items are pre-composed before entering the viewport, causing premature or false impressions. Use `Modifier.onVisibilityChanged` with dwell thresholds instead. - **Lifecycle-Aware Flow Collection** : Use `collectAsStateWithLifecycle()` for UI Flow collections that actively produce background work on Android, and `collectAsState()` for lightweight in-memory StateFlows or non-Android scopes. diff --git a/.agents/skills/jetpack-compose-performance/references/android/develop/ui/compose/performance/backwards-write.md b/.agents/skills/jetpack-compose-performance/references/android/develop/ui/compose/performance/backwards-write.md index 7ee2939..d878c0a 100644 --- a/.agents/skills/jetpack-compose-performance/references/android/develop/ui/compose/performance/backwards-write.md +++ b/.agents/skills/jetpack-compose-performance/references/android/develop/ui/compose/performance/backwards-write.md @@ -168,19 +168,23 @@ fun OkCounter() {
+> [!NOTE] +> **Note:** A backwards write strictly requires a state read to precede the state write (`Read -> Write`). Assigning to a `MutableState` at the start of a composable before any read occurs (`Write -> Read`, or writing in Composition and reading later in the Draw phase) is not a backwards write. However, wrapping a synchronous value in `remember { mutableStateOf(...) }` only to overwrite it immediately in Composition adds unnecessary `MutableState` and slot table overhead (and risks becoming a backwards write if a read is later added before the assignment)---prefer a plain local `val` or read the value directly in the target phase. + *** ** * ** *** ## Key rules to prevent backwards writes -1. **Don't write to state in `onGloballyPositioned`, `onSizeChanged`, or - `LayoutModifier` if that state is read in Composition** as this causes the first-frame-correctness problem. +1. **Don't write to state in `onGloballyPositioned`, `onSizeChanged`, + `onPlaced`, `onLayoutRectChanged`, or `LayoutModifier` if that state is read + in Composition** as this causes the first-frame-correctness problem. - If layout coordinates or sizes are only needed for custom drawing, read them directly in the Draw or Layout phase (for example, using `Modifier.drawWithCache` or `Modifier.layout`). - **For Window-Level Sizing (`WindowWidthSizeClass`):** Hoist size observation to the window level. Composition branches on window size classes before local measurement occurs. - **Keep Composition Uniform:** Use a single custom Layout or components like `FlowRow` or `LazyVerticalGrid` that adjust measurement and placement during Phase 2 without recomposing or altering state used in composition. - **Use Sub-composition:** Use `BoxWithConstraints` or `SubcomposeLayout` when child composables must branch based on local width or height. Use caution: Subcomposition carries a performance cost and can usually be avoided. - **As a last resort:** Allow the first frame to be wrong, storing size in `onSizeChanged` to trigger a second frame recomposition. This causes visible layout popping, jank, and risks infinite loops. -2. **Don't mutate state after its read the first time in composition** : - - Although you may safely write to `MutableState` objects during composition outside of a`SideEffect`, take special care to ensure that you don't write to a state that you may have previously read in composition. It's recommended to use `rememberUpdatedState` when this need for a state write in composition arises. Writing to a state during composition in another way is usually a sign of a missing effect or an inappropriately designed state or composable. Remember that composition is optimistic and always executes with the latest value of a state, so you may not see all state changes in recompositions. UI updates shouldn't be used as a way of handling one-time events, which makes it uncommon for a state's value to require being updated as a result of recomposing. +2. **Don't mutate state after it's read the first time in composition** : + - Although you may safely write to `MutableState` objects during composition outside of a `SideEffect`, take special care to ensure that you don't write to a state that you may have previously read in composition. It's recommended to use `rememberUpdatedState` when this need for a state write in composition arises. Writing to a state during composition in another way is usually a sign of a missing effect or an inappropriately designed state or composable. Remember that composition is optimistic and always executes with the latest value of a state, so you may not see all state changes in recompositions. UI updates shouldn't be used as a way of handling one-time events, which makes it uncommon for a state's value to require being updated as a result of recomposing. - Avoid mutating states that are observed outside of composition (for example, `ViewModel` fields or `isVisible` flags). State writes that affect composition belong in event lambdas (`onClick`), coroutines (`LaunchedEffect`), or side effects (`SideEffect`). `rememberUpdatedState` is an exception as it is designed to mutate state that is only used in `@Composable` body. 3. **Defer state reads to the latest possible phase** : - Reading states in Draw (`Modifier.graphicsLayer { alpha = ... }`) or Layout (`Modifier.offset { IntOffset(...) }`) ensures that changes only invalidate Phase 2 or 3, completely skipping Phase 1 (Composition). \ No newline at end of file diff --git a/.agents/skills/jetpack-compose-performance/references/android/develop/ui/compose/performance/stability/strongskipping.md b/.agents/skills/jetpack-compose-performance/references/android/develop/ui/compose/performance/stability/strongskipping.md index 8dbe2a3..55ff6b1 100644 --- a/.agents/skills/jetpack-compose-performance/references/android/develop/ui/compose/performance/stability/strongskipping.md +++ b/.agents/skills/jetpack-compose-performance/references/android/develop/ui/compose/performance/stability/strongskipping.md @@ -54,12 +54,15 @@ want a restartable but non-skippable composable. In this case, use the ### Annotate classes as stable -If you want an object using object equality instead of instance equality, -continue to annotate the given class with `@Stable`. -An example of when you might -have to do this is when observing an entire list of objects, data sources such -as Room will allocate new objects for every item in the list any time one of -them changes. +If you want an object to be compared using object equality (`Object.equals()`) +instead of instance equality (`===`), continue to annotate the given class with +`@Stable` or `@Immutable`. + +Keep the following trade-offs in mind before adding `@Stable` or `@Immutable`: + +- **Classes with stable properties in Compose modules are already stable** : A class with `val` properties of stable types in a module compiled with the Compose compiler is automatically inferred as stable and uses `.equals()` without needing `@Immutable` or `@Stable`. +- **Only annotate classes with cheap `.equals()` checks** : Add `@Stable` or `@Immutable` when the compiler can't infer stability (such as classes from non-Compose modules) and new instances with identical values are frequently allocated, provided the class's `.equals()` check is inexpensive. +- **Be cautious when annotating models with collections** : Adding `@Stable` or `@Immutable` to models containing collections (or replacing `List` with `ImmutableList`) causes Compose to compare every element in the collection with `.equals()` on each recomposition pass. For large collections or models used in lazy layouts (`LazyColumn`, `LazyRow`, `LazyGrid`), comparing every element can be slower than using instance equality (`===`) or recomposing directly. ## Lambda memoization diff --git a/.agents/skills/jetpack-compose-performance/references/animation.md b/.agents/skills/jetpack-compose-performance/references/animation.md index 6fdce35..5504025 100644 --- a/.agents/skills/jetpack-compose-performance/references/animation.md +++ b/.agents/skills/jetpack-compose-performance/references/animation.md @@ -2,9 +2,9 @@ When animating visual properties (such as alpha, scale, rotation, color, or translation), never read the animated state directly in the Composable body -during the Composition phase. Reading state during composition causes the entire -composable tree to recompose on every animation tick (60 to 120 times per -second). +during the Composition phase. Reading state during composition causes the +reading recompose scope (and any non-skippable children) to recompose on every +animation tick (60 to 120 times per second). Wrap animated state reads in lambdas so evaluation is deferred to the **Draw** or diff --git a/.agents/skills/jetpack-compose-performance/references/effects-and-threading.md b/.agents/skills/jetpack-compose-performance/references/effects-and-threading.md index 46ad67b..9c96851 100644 --- a/.agents/skills/jetpack-compose-performance/references/effects-and-threading.md +++ b/.agents/skills/jetpack-compose-performance/references/effects-and-threading.md @@ -488,20 +488,20 @@ nested `launch { ... }` calls instead of declaring multiple standalone Understanding the execution lifecycle and performance costs of Compose effects is critical when choosing between them: -#### A. Execution order relative to frame rendering +#### A. Execution order when composition changes are applied -Compose executes effects in a strictly defined order across frame phases: +When composition changes are applied (`RememberObserver.onRemembered`), Compose +triggers effects in the following order: -1. **`DisposableEffect`**: Executes first, immediately after composition changes are applied, in the exact order declared in the composable. -2. **`SideEffect`** : Executes second, after all `DisposableEffect`s have run, before layout and drawing occur. -3. **Layout \& Draw**: The UI hierarchy measures, places, and draws pixels onto the screen. -4. **`LaunchedEffect`** : Executes **after Draw at the end of the frame**, scheduled on the coroutine dispatcher. +1. **`DisposableEffect`**: Executes synchronously first as composition changes are applied, in the order declared in the composable. +2. **`SideEffect`** : Executes synchronously second, after `DisposableEffect`s have run, before layout and drawing occur. +3. **`LaunchedEffect`** : Launches its coroutine on the composition's `CoroutineContext` when composition changes are applied. Because it is dispatched asynchronously on that coroutine dispatcher, it does not run synchronously during the apply pass, and it is **not** guaranteed to run after Layout and Draw. > **Warning:** -> If your code implicitly relies on `LaunchedEffect` running after the frame has -> rendered (e.g., waiting for layout bounds to be drawn), migrating to -> `SideEffect` will execute *before* Layout and Draw. Always verify execution -> order dependencies when refactoring effects. +> Do not assume `LaunchedEffect` waits until after Layout and Draw to execute. +> If your code needs layout bounds or coordinates, observe them through layout +> modifiers (`onPlaced`, `onSizeChanged`, or `onLayoutRectChanged`) rather than +> relying on effect dispatch timing. #### B. Effect allocation and teardown costs diff --git a/.agents/skills/jetpack-compose-performance/references/graphics-performance.md b/.agents/skills/jetpack-compose-performance/references/graphics-performance.md index 04bac6f..7be8d24 100644 --- a/.agents/skills/jetpack-compose-performance/references/graphics-performance.md +++ b/.agents/skills/jetpack-compose-performance/references/graphics-performance.md @@ -179,8 +179,8 @@ small UI icons. - **Synchronous Main-Thread Decoding** : `painterResource` decodes bitmaps synchronously on the main thread during composition (`Compose:recompose` -\> `ImageDecoder`), including during lazy list prefetch (`lazy:prefetch:compose`). Even a small compressed JPEG in the APK (e.g., 159 KB) can decompress into a large `2760x1840` bitmap, blocking the main thread and causing dropped frames. - **No Target-Size Downsampling** : `painterResource` has no awareness of the layout size (such as `Modifier.size(160.dp)`) and decodes the image at full original resolution. -- **Prefer Async image loading like Coil's `AsyncImage` (or Asynchronous - Downsampling)** : +- **Prefer Async image loading like Coil's `AsyncImage` (from + `io.coil-kt.coil3:coil-compose`, or Asynchronous Downsampling)** : - **Offloads Decoding** : `AsyncImage` decodes images asynchronously on a background thread, keeping the main thread free during scroll and prefetch. - **Automatic Downsampling (`ImageDecoder.setTargetSize`)** : `AsyncImage` measures the target layout bounds and configures `ImageDecoder.setTargetSize()` automatically so only the pixels actually displayed are decoded and uploaded to the GPU. - **Ship Appropriately Sized Assets**: If bundling static thumbnails in the APK, ship pre-scaled thumbnail assets matching the display bucket rather than full-resolution camera photos. diff --git a/.agents/skills/jetpack-compose-performance/references/layouts.md b/.agents/skills/jetpack-compose-performance/references/layouts.md index 72a4272..572c1df 100644 --- a/.agents/skills/jetpack-compose-performance/references/layouts.md +++ b/.agents/skills/jetpack-compose-performance/references/layouts.md @@ -1,9 +1,10 @@ ## 1. Layout coordinates, rects, and visibility tracking -`Modifier.onGloballyPositioned` executes on every layout pass across the entire -Compose hierarchy whenever any node is placed or moved, making it a frequent -source of frame drops, unnecessary callbacks, and layout feedback loops. Use -targeted modern APIs instead: +`Modifier.onGloballyPositioned` fires for its node whenever that node's global +position in the window may have changed after a layout pass (for example, on +every frame during scrolling or animation when an ancestor moves), making it a +frequent source of frame drops, unnecessary callbacks, and layout feedback +loops. Use targeted modern APIs instead: ### 1. Viewport and dwell tracking: use `Modifier.onVisibilityChanged` @@ -31,10 +32,11 @@ Box(
-### 2. Local bounds and placement: prefer `onPlaced` or `onLayoutRectChanged` +### 2. Local placement and throttled bounds: prefer `onPlaced` or `onLayoutRectChanged` - **Dimensions Only (`IntSize`)** : If you only need width and height, use `Modifier.onSizeChanged`. It executes only when the node's dimensions change, ignoring position shifts. -- **Local Coordinates after Placement** : Use `Modifier.onPlaced` or `Modifier.onLayoutRectChanged` when you need local coordinates relative to the parent without subscribing to global window or screen coordinate updates across the entire tree. +- **Local Coordinates after Placement (`Modifier.onPlaced`)** : Use `Modifier.onPlaced` when you need placement coordinates relative to the parent after the node is placed, without subscribing to window or screen movement from ancestors. +- **Throttled Bounds Tracking (`Modifier.onLayoutRectChanged`)** : When you need root-, window-, or screen-relative bounds (`RelativeLayoutBounds`), prefer `Modifier.onLayoutRectChanged` over `onGloballyPositioned`; it supports built-in `throttleMillis` and `debounceMillis` parameters to avoid firing on every frame. ### 3. On-demand coordinate queries: avoid `MutableState` position tracking diff --git a/.agents/skills/litert-runtime/SKILL.md b/.agents/skills/litert-runtime/SKILL.md index 3f48421..5875dd9 100644 --- a/.agents/skills/litert-runtime/SKILL.md +++ b/.agents/skills/litert-runtime/SKILL.md @@ -3,14 +3,15 @@ name: litert-runtime description: Configures and implements LiteRT (.tflite) on-device inference on Android in Kotlin using the CompiledModel API on CPU and GPU. Use this skill for any Android LiteRT or .tflite task, including Gradle setup, image classification or vision/audio/embedding - helpers, TensorBuffer reuse, Bitmap preprocessing, AndroidViewModel GPU-to-CPU fallback - with warm-up on a single-thread dispatcher, migrating from legacy org.tensorflow.lite.Interpreter - or GpuDelegate, and latency benchmarking. Don't use for running generative text - LLMs with .litertlm files or for Python model conversion. + helpers, TensorBuffer reuse, CPU memory optimization, Bitmap preprocessing, AndroidViewModel + GPU-to-CPU fallback with warm-up on a single-thread dispatcher, LiteRT in Google + Play services (play-services-litert), migrating from legacy org.tensorflow.lite.Interpreter, + GpuDelegate, or play-services-tflite, and latency benchmarking. Don't use for running + generative text LLMs with .litertlm files or for Python model conversion. license: Complete terms in LICENSE.txt metadata: author: Google LLC - last-updated: '2026-10-20' + last-updated: '2026-10-22' keywords: - LiteRT - CompiledModel @@ -30,8 +31,7 @@ skill. ## Prerequisites - A Kotlin Android project (`minSdk` must be `24` or higher, as declared by the LiteRT 2.3.0 AARs). -- The dependencies in the app-level `build.gradle.kts` file from Google Maven: `implementation("com.google.ai.edge.litert:litert:2.3.0")` and `implementation("com.google.ai.edge.litert:litert-gpu:2.3.0")`. From 2.3.0 the GPU accelerator is the second artifact: with `litert` alone the app builds, and `CompiledModel.create` with `Accelerator.GPU` throws `LiteRtException` because the APK has no GPU accelerator library (Logcat logs `GPU accelerator - could not be loaded and registered`). No manifest entry is needed: `litert-api`, which `litert` depends on, declares the OpenCL driver libraries (`uses-native-library`) in its own manifest. +- The dependencies in the app-level `build.gradle.kts` file from Google Maven: `implementation("com.google.ai.edge.litert:litert:2.3.0")` and `implementation("com.google.ai.edge.litert:litert-gpu:2.3.0")`. From 2.3.0 the GPU accelerator is the second artifact: with `litert` alone the app builds, and `CompiledModel.create` with `CompiledModel.Options(Accelerator.GPU)` throws `LiteRtException` because the APK has no GPU accelerator library. With `CompiledModel.Options(Accelerator.GPU, Accelerator.CPU)` it doesn't throw: the model runs on the CPU while the app reports the GPU, and only Logcat shows it (`GPU accelerator could not be loaded and registered`). No manifest entry is needed: `litert-api`, which `litert` depends on, declares the OpenCL driver libraries (`uses-native-library`) in its own manifest. - A verified `.tflite` model and its input contract (input dimensions, mean, standard deviation, channel order, and tensor layout). For models with a verified recipe, inspect the [LiteRT model recipes](https://github.com/google-ai-edge/litert-samples/blob/main/models/README.md). ## Critical mandates @@ -340,7 +340,7 @@ existing app, you must read [migration](references/migration.md), which gives th for each step: 1. **Find the runtime**: Read the build files and the resolved dependencies, and note the delegate, the thread count, and the Support library classes that the app uses. -2. **Capture a golden output first**: While the legacy runtime is still in the project, record the app's output for one fixed input on the CPU. This step needs a device or an emulator. +2. **Capture a golden output first** : While the legacy runtime is still in the project, record the app's output for one fixed input on the CPU. This step needs a device or an emulator. Run the capture test with `am instrument`, not `connectedDebugAndroidTest`, which uninstalls the app and deletes the file. 3. **Swap the dependencies** : Remove every legacy artifact and add the dependencies of Step 1. An app on the Support library keeps it as `com.google.ai.edge.litert:litert-support:2.3.0`, with the package `org.tensorflow.lite.support` renamed to `com.google.ai.edge.litert.support` ([LiteRT Support migration guide](https://github.com/google-ai-edge/LiteRT/blob/main/litert/support_lib/MIGRATION_GUIDE.md)). 4. **Replace the delegates** : `GpuDelegate` becomes `CompiledModel.Options(Accelerator.GPU, Accelerator.CPU)`, so the CPU runs the operators that the GPU doesn't support. An NNAPI choice in the app maps to the same GPU option, not to `Accelerator.CPU` alone or to an NPU accelerator. `setNumThreads(n)` becomes new CPU options with `cpuOptions = CompiledModel.CpuOptions(numThreads = n)`. 5. **Rewrite the inference code** : Replace `Interpreter` and `FileUtil.loadMappedFile` with the `Classifier` helper of Step 2, created and run only on the single-thread executor of Step 3, never in a constructor that the main thread calls. @@ -357,7 +357,7 @@ Migration mandates that apply to every migrated app: - **Never** allocate new `TensorBuffer` instances on every inference call; failing to close per-frame `TensorBuffer` objects leaks C++ heap memory. - **Never** benchmark `model.run` without including output buffer readback (`outputs[0].readFloat`). - **Never** mix `org.tensorflow:tensorflow-lite*` artifacts (such as `org.tensorflow:tensorflow-lite-support`) with `com.google.ai.edge.litert:*` artifacts; both ship the `org.tensorflow.lite.*` classes and cause duplicate class build failures. -- **Never** use `Accelerator.GPU` on LiteRT 2.3.0 without adding the `com.google.ai.edge.litert:litert-gpu:2.3.0` dependency alongside `com.google.ai.edge.litert:litert:2.3.0`. +- **Never** use `Accelerator.GPU` on LiteRT 2.3.0 without adding the `com.google.ai.edge.litert:litert-gpu:2.3.0` dependency alongside `com.google.ai.edge.litert:litert:2.3.0`; with the CPU as a second accelerator, the model silently runs on the CPU. ## Checklist diff --git a/.agents/skills/litert-runtime/references/migration.md b/.agents/skills/litert-runtime/references/migration.md index 41f2296..e7c3544 100644 --- a/.agents/skills/litert-runtime/references/migration.md +++ b/.agents/skills/litert-runtime/references/migration.md @@ -44,8 +44,29 @@ The golden output is the legacy app's output for one fixed input, recorded before any dependency changes. Step 6 compares the migrated app with it. Pick one path: -- **Path A (device or emulator connected)** : Write an instrumented test with the project's existing test setup. The test feeds one deterministic input through the app's own inference class and preprocessing on the CPU, without the GPU or NNAPI delegate. Use a bitmap of another size than the model input, never a camera frame. Write the output with `writeGoldenOutput`, then copy the file to `app/src/androidTest/assets/golden/output.txt`. -- **Path B (no device)**: Don't capture. Report the capture and the device comparison of Step 6 to the user as post-implementation steps. +- **Path A (device or emulator connected)** : Write an instrumented test with + the project's existing test setup. The test feeds one deterministic input + through the app's own inference class and preprocessing on the CPU, without + the GPU or NNAPI delegate. Use a bitmap of another size than the model input, + never a camera frame. Write the output with `writeGoldenOutput` to a file in + `InstrumentationRegistry.getInstrumentation().targetContext.filesDir`. When + the model has signatures, also log the legacy + `interpreter.getSignatureInputs(key)` and `getSignatureOutputs(key)`: the + migrated by-name calls need these names (Step 5). When the model has no + signatures, log `interpreter.getInputTensor(i).name()` and + `getOutputTensor(i).name()` instead. Don't run the capture test + with `./gradlew connectedDebugAndroidTest`: it uninstalls the app when the + test ends, which deletes the file. Install both APKs, run the test with + `am instrument`, and copy the file out of the debuggable app: + + ./gradlew installDebug installDebugAndroidTest + adb shell pm list instrumentation # shows / for the app + adb shell am instrument -w -e class / + mkdir -p app/src/androidTest/assets/golden + adb shell run-as cat files/golden.txt > app/src/androidTest/assets/golden/output.txt + +- **Path B (no device)**: Don't capture. Report the capture and the device + comparison of Step 6 to the user as post-implementation steps. // Before the dependency swap: record the legacy app's output for one fixed // input, one value per line. @@ -60,8 +81,11 @@ one path: return golden.indices.maxOf { abs(golden[it] - migrated[it]) } } - fun readGoldenOutput(context: Context, path: String = "golden/output.txt"): FloatArray = - context.assets.open(path).bufferedReader().use { reader -> + // The golden file is an asset of the test APK: pass + // InstrumentationRegistry.getInstrumentation().context, not targetContext, + // whose assets throw FileNotFoundException for it. + fun readGoldenOutput(testContext: Context, path: String = "golden/output.txt"): FloatArray = + testContext.assets.open(path).bufferedReader().use { reader -> reader.readLines().filter { it.isNotBlank() }.map { it.toFloat() }.toFloatArray() } @@ -127,8 +151,8 @@ Keep the app's own classes and their public functions as the callers. - **Model loading** : `Interpreter` from `FileUtil.loadMappedFile`, and the Support `Model.createModel`, become `CompiledModel.create` in the helper. No overload takes a `ByteBuffer`. - **Inference** : `run` on `ByteBuffer` objects becomes `writeFloat`, `run`, and `readFloat` on buffers that the helper creates once. Use `writeInt8` and `readInt8` for int8 and uint8 tensors. - **Thread count** : `setNumThreads` becomes `CpuOptions(numThreads = n)` on new CPU options. -- **Tensor shapes** : `getInputTensor` and its `shape` become a lookup by name. `resizeInput` has no counterpart in 2.3.0. -- **Signatures** : `runSignature` becomes `run` with maps from tensor names to buffers. +- **Tensor shapes** : `getInputTensor` and its `shape` become a lookup by name. `getInputTensorType` throws `LiteRtException` for `uint8` tensors; for the size, use `getInputBufferRequirements(name, signature).bufferSize`, which is in bytes for every element type. `resizeInput` has no counterpart in 2.3.0. +- **Signatures** : `runSignature` becomes `run` with maps from the signature's input and output names to buffers. Every by-name call (`run` with maps, `getInputTensorType`, `getInputBufferRequirements`) takes the names in the signature, such as `x` or `args_0` and `output_0`, not the graph tensor names such as `serving_default_x:0`. When the model has no signatures, the names are its input and output tensor names (such as `images` and `Softmax`). `CompiledModel` has no call that lists them, so take them from the legacy code (Step 2). A missing or wrong name in an output map crashes the process (JNI abort) instead of throwing, so check the names before the first `run`. - **Closing** : Closing the interpreter becomes the helper's `close`. // Replaces Interpreter.Options.setNumThreads. Create new options; never change @@ -138,13 +162,19 @@ Keep the app's own classes and their public functions as the callers. cpuOptions = CompiledModel.CpuOptions(numThreads = numThreads) } - // Replaces interpreter.getInputTensor(0).shape(). Look the tensor up by name. + // Replaces interpreter.getInputTensor(0).shape() for float tensors. Look the + // tensor up by its signature name; this throws for uint8 tensors. fun inputShape(model: CompiledModel, inputName: String): List? = model.getInputTensorType(inputName).layout?.dimensions + // Replaces interpreter.getInputTensor(0).numBytes() for any element type. + fun inputSizeBytes(model: CompiledModel, inputName: String, signature: String): Int = + model.getInputBufferRequirements(inputName, signature).bufferSize + // Replaces interpreter.runSignature. Create the buffers once with // model.createInputBuffers(signature) and model.createOutputBuffers(signature), - // and reuse them for every call. + // and reuse them for every call. The map keys are the signature's input and + // output names (such as "args_0" and "output_0"). fun runSignature( model: CompiledModel, signature: String, @@ -207,7 +237,8 @@ leftover Interpreter code, because the `litert` 2.3.0 artifact still contains **Stage 2, on a device**: Pick one path: -- **Path A (golden output captured in Step 2)** : Extend the instrumented test of Step 2. Run the same input through the migrated preprocessing and the helper on `Accelerator.CPU`, and read the golden file with `readGoldenOutput`. The test must fail when `maxAbsDifference` is 1e-3 or more. When the golden file holds only one value, the top-1 index must match; a wrong normalization often still passes this check. Then compare the CPU and the GPU as in Step 5 of the skill. +- **Path A (golden output captured in Step 2)** : Extend the instrumented test of Step 2. Run the same input through the migrated preprocessing and the helper on `Accelerator.CPU`, and read the golden file with `readGoldenOutput(InstrumentationRegistry.getInstrumentation().context)`. The test must fail when `maxAbsDifference` is 1e-3 or more. When the golden file holds only one value, the top-1 index must match; a wrong normalization often still passes this check. Then compare the CPU and the GPU as in Step 5 of the skill; on the bundled runtime, first check that Logcat shows `Dynamically loaded GPU accelerator` and not `GPU accelerator + could not be loaded`, because the GPU and CPU option runs on the CPU without an error when `litert-gpu` is missing. - **Path B (no device)**: Run Stage 1 only, and report that Stage 2 didn't run. ## Out of scope diff --git a/.agents/skills/media3-cast-integration/SKILL.md b/.agents/skills/media3-cast-integration/SKILL.md index 02a3049..c385303 100644 --- a/.agents/skills/media3-cast-integration/SKILL.md +++ b/.agents/skills/media3-cast-integration/SKILL.md @@ -8,7 +8,7 @@ description: Implements Google Cast support in Android apps using Jetpack Media3 license: Complete terms in LICENSE.txt metadata: author: Google LLC - last-updated: '2026-10-14' + last-updated: '2026-10-22' keywords: - Android - Media3 diff --git a/.agents/skills/media3-cast-integration/references/android/media/media3/cast/customize-castoptions.md b/.agents/skills/media3-cast-integration/references/android/media/media3/cast/customize-castoptions.md index e5e5b3e..058d2ed 100644 --- a/.agents/skills/media3-cast-integration/references/android/media/media3/cast/customize-castoptions.md +++ b/.agents/skills/media3-cast-integration/references/android/media/media3/cast/customize-castoptions.md @@ -83,8 +83,7 @@ public static final class MyCustomCastOptionsProvider implements OptionsProvider } @Override - @Nullable - public List getAdditionalSessionProviders(Context context) { + public @Nullable List getAdditionalSessionProviders(Context context) { return null; } } diff --git a/.agents/skills/navigation-3/SKILL.md b/.agents/skills/navigation-3/SKILL.md index 7fc53e6..72329cc 100644 --- a/.agents/skills/navigation-3/SKILL.md +++ b/.agents/skills/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-10-17' + last-updated: '2026-10-22' keywords: - recipe - Android diff --git a/.agents/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/multiple-backstacks.md b/.agents/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/multiple-backstacks.md index 8fb2270..f6c5cca 100644 --- a/.agents/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/multiple-backstacks.md +++ b/.agents/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/multiple-backstacks.md @@ -102,9 +102,7 @@ package com.example.nav3recipes.multiplestacks import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSerializable import androidx.compose.runtime.setValue import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.NavBackStack @@ -113,8 +111,6 @@ import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.rememberDecoratedNavEntries import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator -import androidx.navigation3.runtime.serialization.NavKeySerializer -import androidx.savedstate.compose.serialization.serializers.MutableStateSerializer /** * Create a navigation state that persists config changes and process death. @@ -128,12 +124,7 @@ fun rememberNavigationState( topLevelRoutes: Set ): NavigationState { - val topLevelRoute = rememberSerializable( - startRoute, topLevelRoutes, - serializer = MutableStateSerializer(NavKeySerializer()) - ) { - mutableStateOf(startRoute) - } + val topLevelRoute = rememberNavKey(startRoute) // Create a back stack for each top level route. val backStacks = topLevelRoutes.associateWith { key -> rememberNavBackStack(key) } diff --git a/.agents/skills/play-policy-insights/scripts/generate_report.py b/.agents/skills/play-policy-insights/scripts/generate_report.py old mode 100644 new mode 100755 diff --git a/.agents/skills/play-policy-insights/scripts/orchestrator.py b/.agents/skills/play-policy-insights/scripts/orchestrator.py old mode 100644 new mode 100755 diff --git a/.agents/skills/play-policy-insights/scripts/play_store_scraper.py b/.agents/skills/play-policy-insights/scripts/play_store_scraper.py old mode 100644 new mode 100755 diff --git a/.agents/skills/play-policy-insights/scripts/scanner.py b/.agents/skills/play-policy-insights/scripts/scanner.py old mode 100644 new mode 100755 diff --git a/.agents/skills/wear-compose-m3/SKILL.md b/.agents/skills/wear-compose-m3/SKILL.md index 1ae6563..74f0ce8 100644 --- a/.agents/skills/wear-compose-m3/SKILL.md +++ b/.agents/skills/wear-compose-m3/SKILL.md @@ -8,7 +8,7 @@ description: Guidance for Wear OS Compose Material3 (Wear Compose Material 3 / W license: Complete terms in LICENSE.txt metadata: author: Google LLC - last-updated: '2026-10-14' + last-updated: '2026-10-22' keywords: - Wear OS - Compose diff --git a/.agents/skills/wear-compose-m3/references/android/training/wearables/compose/migrate-to-material3.md b/.agents/skills/wear-compose-m3/references/android/training/wearables/compose/migrate-to-material3.md index ccc3d1a..bbee8d6 100644 --- a/.agents/skills/wear-compose-m3/references/android/training/wearables/compose/migrate-to-material3.md +++ b/.agents/skills/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.8.0-alpha01") + implementation("androidx.wear.compose:compose-material3:1.8.0-alpha02") See the latest M3 versions on the [Wear Compose releases page](https://developer.android.com/jetpack/androidx/releases/wear-compose). -Wear Compose Foundation library version 1.8.0-alpha01 introduced +Wear Compose Foundation library version 1.8.0-alpha02 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.8.0-alpha01") - implementation("androidx.wear.compose:compose-navigation:1.8.0-alpha01") + implementation("androidx.wear.compose:compose-foundation:1.8.0-alpha02") + implementation("androidx.wear.compose:compose-navigation:1.8.0-alpha02") ## Theme @@ -330,7 +330,7 @@ equivalents: And finally a list of some relevant components from Wear Compose Foundation library: -| Wear Compose Foundation 1.8.0-alpha01 | | +| Wear Compose Foundation 1.8.0-alpha02 | | |---|---| | [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. |