diff --git a/.claude/skills/adaptive/SKILL.md b/.claude/skills/adaptive/SKILL.md
new file mode 100644
index 00000000..64030c6b
--- /dev/null
+++ b/.claude/skills/adaptive/SKILL.md
@@ -0,0 +1,301 @@
+---
+name: adaptive
+description: Instructions to make or update an app's UI so that it adapts to different
+ Android devices including phones, tablets, foldables, laptops, desktop, TV, Auto
+ and XR. It includes how to handle different window sizes, pointing devices (such
+ as mouse) and text entry devices (such as keyboard) using the Compose MediaQuery
+ API. 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.
+license: Complete terms in LICENSE.txt
+metadata:
+ author: Google LLC
+ last-updated: '2026-05-20'
+ keywords:
+ - android
+ - ui
+ - adaptive
+ - Grid
+ - FlexBox
+ - MediaQuery
+ - navigation
+---
+
+## Prerequisites
+
+The app must:
+
+- Use Compose for all screens. If it's still using Fragments or Views, suggest using the XML to Compose skill to migrate those screens.
+- Use Jetpack Navigation 3. If it doesn't, suggest the Navigation 3 skill to migrate the app.
+
+## Workflow to make an app adaptive
+
+To make an app adaptive, follow these steps or a subset of them adapting to the
+task.
+
+- Step 1: Verify current UI
+- Step 2: Make the navigation bar adaptive
+- Step 3: Add multi-pane layouts
+- Step 4: Make vertical lists adaptive by changing the number of columns
+- Step 5: Hide app bars when scrolling
+
+## Step 1. Verify current UI
+
+Ensure that screenshot tests exist to verify the current UI on different form
+factors. If they don't exist, add the [Compose Preview Screenshot Testing
+tool](references/android/develop/ui/compose/tooling/debug.md). Use the following annotation to create previews for all the major form
+factors. For example:
+
+
+```kotlin
+@Preview(name = "Phone", device = Devices.PHONE, showBackground = true)
+@Preview(name = "Foldable", device = Devices.FOLDABLE, showBackground = true)
+@Preview(name = "Tablet", device = Devices.TABLET, showBackground = true)
+@Preview(name = "Desktop", device = Devices.DESKTOP, showBackground = true)
+annotation class FormFactorPreviews
+
+@PreviewTest
+@FormFactorPreviews
+@Composable
+fun FeedScreenPreview() {
+ SnippetsTheme {
+ Box {
+ Text("My Screen")
+ }
+ }
+}
+```
+
+
+
+## Step 2. Make the navigation bar adaptive
+
+Bottom navigation bars are optimized for touch input when the user is holding a
+phone in portrait mode. On larger screen hand-held devices, like tablets and
+unfolded foldables, the navigation area must be accessible from the edge of the
+screen (navigation rail).
+
+If you need to provide more screen real state for the content, hide the
+navigation area. Examples of this include:
+
+- Hiding the navigation bar when the user scrolls down and showing it again when the user scrolls up. The assumption is that when the user is scrolling down, they are consuming content but when scrolling up they are trying to navigate away from that content.
+- Hiding the navigation area when its content is distracting. For example, in camera previews or when the content is best displayed in full screen (such as a single photo screen).
+
+When the detail screen is displayed full-screen on mobile, full-screen mode must
+be deactivated on larger screens.
+
+Steps to migrate:
+
+- Locate the existing navigation bar.
+- Convert each item to a `NavigationSuiteItem`.
+- Identify whether the navigation bar's visibility changes. For example, if it is wrapped with an `AnimatedContent` or `AnimatedVisibility` composable. If so, follow the guidance in the "Control navigation area visibility".
+- Replace the container that held the navigation bar (often a `Scaffold`) with `NavigationSuiteScaffold` from the Material 3 adaptive layouts library.
+- Supply the navigation items using the `navigationItems` parameter of `NavigationSuiteScaffold`.
+
+### Step 2.1. Control navigation area visibility
+
+If the navigation bar's visibility changes - it is hidden under certain
+scenarios or on certain screens - this behavior must be maintained with the
+adaptive navigation area. This is done using `NavigationSuiteScaffold`'s `state`
+parameter.
+
+Steps to migrate:
+
+- Identify the scenarios under which the navigation bar is hidden. This is usually done with a boolean variable for the visibility. It could be named something like `isNavBarVisible` or `shouldShowNavBar`.
+- Create an instance of `NavigationSuiteScaffoldState` using `rememberNavigationSuiteScaffoldState()` and pass it to `NavigationSuiteScaffold`.
+- When the navigation area visibility changes, use a `LaunchedEffect` to call `show` or `hide` on the `NavigationSuiteScaffoldState`.
+
+For example:
+
+
+```kotlin
+// Pass this variable to any composable that needs to control the navigation area visibility
+var isNavBarVisible by remember { mutableStateOf(true) }
+val scaffoldVisibilityState = rememberNavigationSuiteScaffoldState()
+
+NavigationSuiteScaffold(
+ navigationSuiteItems = navItems,
+ state = scaffoldVisibilityState
+) {
+ // Main content
+}
+
+LaunchedEffect(isNavBarVisible){
+ if (isNavBarVisible) {
+ scaffoldVisibilityState.show()
+ } else {
+ scaffoldVisibilityState.hide()
+ }
+}
+```
+
+
+
+## Step 3. Add multi-pane layouts using Navigation 3 Scenes
+
+Analyze the codebase looking for related screens - tapping on something in one
+screen opens another screen that shows information related to the first. There
+are two canonical screen relationships: list-detail and supporting pane.
+
+IMPORTANT: You must use the Navigation 3 `SceneStrategy` approach to implement
+multi-pane layouts. Do not use `ListDetailPaneScaffold` or
+`SupportingPaneScaffold`.
+
+### Step 3.1. List-detail
+
+#### Identify the list and detail screens
+
+List-detail layouts display a list of items (this is the list screen) and
+clicking on an item opens a new screen that shows more details about that item
+(the detail screen).
+
+Typical usage includes productivity apps like email, notes, and messaging.
+
+Unless requested explicitly, avoid this pattern when the detail content requires
+substantial screen space (e.g., images or media that benefits from a full-screen
+presentation).
+
+#### Add a Material list-detail SceneStrategy
+
+- Add the `androidx.compose.material3.adaptive:adaptive-navigation3` library
+- Create an `androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy` using `rememberListDetailSceneStrategy`
+- Pass the `ListDetailSceneStrategy` to `NavDisplay` using its `sceneStrategies` parameter
+
+#### Use metadata to identify the list and detail screens
+
+- Add metadata using `entry(metadata = ...)` or `NavEntry(metadata = ...)` to the list entry using `ListDetailSceneStrategy.listPane(detailPlaceholder = {
+ })`.
+- Use the `detailPlaceholder` parameter to add a placeholder on the detail screen when no list items are selected.
+- Add metadata to the detail entry using `ListDetailSceneStrategy.detailPane()`.
+
+#### Important considerations
+
+- When a detail screen displays its content full-screen on mobile (content fills the entire screen, bars or rails are hidden), full-screen mode must be deactivated if it's part of a list-detail layout.
+- Detail screens must not show a back arrow when on a list-detail layout.
+
+For a reference implementation, check the [Nav3 **Material** List Detail
+recipe](references/android/guide/navigation/navigation-3/recipes/material-listdetail.md).
+
+### Step 3.2. Supporting pane
+
+Identify supporting pane screens where a main screen displays a single item, and
+selecting it opens a "supporting screen" with more details. The supporting
+screen complements the main screen and is shown in a supporting pane.
+
+#### Add a Material supporting pane `SceneStrategy`
+
+- If you haven't already, add the `androidx.compose.material3.adaptive:adaptive-navigation3` library
+- Create an `androidx.compose.material3.adaptive.navigation3.SupportingPaneSceneStrategy` using `rememberSupportingPaneSceneStrategy`
+- Pass the `SupportingPaneSceneStrategy` to `NavDisplay` using its `sceneStrategies` parameter
+
+#### Use metadata to identify the main and supporting screens
+
+- Add metadata using `entry(metadata = ...)` or `NavEntry(metadata = ...)` to the main entry using `SupportingPaneSceneStrategy.mainPane()`
+- Add metadata to the supporting entry using `SupportingPaneSceneStrategy.supportingPane()`
+
+### Step 3.3. Run screenshot tests
+
+If you have made changes, record new reference files. Ask the user to visually
+verify that the new layouts are correct.
+
+## Step 4. Make vertical lists adaptive by changing the number of columns
+
+### Step 4.1. Make lazy lists adaptive
+
+Look for the following vertical list composables: `LazyColumn`,
+`LazyVerticalGrid`, `LazyVerticalStaggeredGrid`.
+
+Steps to migrate:
+
+- Choose a suitable minimum width in dp for the column. It should be large enough so that item is clearly visible to the user.
+- For `LazyColumn`: change to a `LazyVerticalGrid` and follow the instruction below
+- For `LazyVerticalGrid`: change the `columns` parameter to use `GridCells.Adaptive(.dp)`
+- For `LazyVerticalStaggeredGrid`: change the `columns` parameter to use `StaggeredGridCells.Adaptive(.dp)`
+
+### Step 4.2. Migrate non-lazy lists to Grid
+
+WARNING: Grid is an experimental API available from Compose 1.11.0-beta01.
+Confirm with the user that they are happy to use an experimental API in their
+codebase.
+
+Look for any `Column` that contains multiple items of the same type and replace
+it with `Grid`. Do not replace it with `LazyVerticalGrid` or any other lazy
+layout. Do not place `Grid` inside the existing `Column`. Completely replace it.
+
+`Grid` is configured by supplying a lambda (an extension function on
+`GridConfigurationScope`) to its `config` parameter. Inside the lambda,
+`constraints` provides the minimum and maximum dimensions of the grid container
+and can be used to change the number of rows and columns based on the available
+size. For example, the following code configures `Grid` such that when the
+available width is:
+
+- less than 800dp, a 2x4 grid is used
+- 800dp or more, a 4x2 grid is used
+
+
+```kotlin
+Grid(
+ config = {
+ val maxWidthDp = constraints.maxWidth.toDp()
+ val (cols, rows) = if (maxWidthDp < 800.dp){
+ 2 to 4
+ } else{
+ 4 to 2
+ }
+
+ val gapSizeDp = 8.dp
+ val cellSize = ((maxWidthDp - (gapSizeDp * (cols - 1))) / cols).coerceAtLeast(0.dp)
+ repeat(cols) { column(cellSize) }
+ repeat(rows) { row(cellSize) }
+ gap(gapSizeDp)
+ }
+) { /** items **/ }
+```
+
+
+
+`Grid` is an experimental API so add the `@OptIn(ExperimentalGridApi::class)`
+annotation to any function that uses it.
+
+## Step 5: Hide App Bars when scrolling
+
+In an app with multiple top-level destinations, each screen must manage its own
+app bar state independently. There are two main scroll behaviors:
+
+- `exitUntilCollapsedScrollBehavior`: Hides on scroll down, stays hidden while you scroll up until you reach the very top (0 offset).
+- `enterAlwaysScrollBehavior`: Hides on scroll down, shows immediately on scroll up.
+
+## Final step: Build and test
+
+Build the app and run the local tests. If the project has screenshot tests, run
+them but DO NOT update the reference images. Prompt the user to do this after
+they have viewed the screenshot diffs.
+
+## Additional documentation for experimental adaptive APIs
+
+The following APIs are available from Compose 1.11.0-beta01.
+
+### FlexBox
+
+Check the FlexBox documentation:
+
+- [Overview](references/android/develop/ui/compose/layouts/adaptive/flexbox/index.md)
+- [Get started - setup](references/android/develop/ui/compose/layouts/adaptive/flexbox/get-started.md)
+- [Set container behavior](references/android/develop/ui/compose/layouts/adaptive/flexbox/container-behavior.md)
+- [Set item behavior](references/android/develop/ui/compose/layouts/adaptive/flexbox/item-behavior.md)
+
+## MediaQuery
+
+Check the [MediaQuery documentation](references/android/develop/ui/compose/layouts/adaptive/mediaquery/index.md) when you need to query the device's
+screen size, pointer precision, keyboard type, whether it has cameras or
+microphones, and other device capabilities.
+
+## Grid
+
+Check the Grid documentation when you need to display a fixed number of items in
+a grid layout:
+
+- [Overview](references/android/develop/ui/compose/layouts/adaptive/grid/index.md)
+- [Get started - setup](references/android/develop/ui/compose/layouts/adaptive/grid/get-started.md)
+- [Set container properties](references/android/develop/ui/compose/layouts/adaptive/grid/container-properties.md)
+- [Set item properties](references/android/develop/ui/compose/layouts/adaptive/grid/item-properties.md)
diff --git a/.claude/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/container-behavior.md b/.claude/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/container-behavior.md
new file mode 100644
index 00000000..ad4862f0
--- /dev/null
+++ b/.claude/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/container-behavior.md
@@ -0,0 +1,112 @@
+To configure the behavior of the `FlexBox` container, create a `FlexBoxConfig`
+block and supply it using the `config` parameter.
+
+
+```kotlin
+FlexBox(
+ config = {
+ direction(FlexDirection.Column)
+ wrap(FlexWrap.Wrap)
+ alignItems(FlexAlignItems.Center)
+ alignContent(FlexAlignContent.SpaceAround)
+ justifyContent(FlexJustifyContent.Center)
+ gap(16.dp)
+ }
+) { // child items
+}
+```
+
+
+
+Use `FlexBoxConfig` to define the layout direction, wrapping behavior,
+alignment, and gaps between items.
+
+## Layout direction
+
+The `direction` function sets the main axis, which dictates the direction
+items are laid out in. It accepts the following values:
+
+- `Row` (default): Sets the main axis to be horizontal. In left-to-right locales this will be left-to-right, with the opposite in right-to-left.
+- `RowReverse`: Reverses the direction of `Row`.
+- `Column`: Sets the main axis to be vertical, top-to-bottom.
+- `ColumnReverse`: Reverses the direction of `Column`.
+
+## Align items and distribute extra space
+
+The following sections describe how to align items and distribute extra space
+along the main and cross axes.
+
+### Along the main axis
+
+Use `justifyContent` to distribute items along the main axis. The following
+table shows the behavior when the direction is `Row`.
+
+|---|---|
+| |  |
+| `Start` |  |
+| `Center` |  |
+| `End` |  |
+| `SpaceBetween` |  |
+| `SpaceAround` |  |
+| `SpaceEvenly` |  |
+
+### Along the cross axis
+
+Use `alignItems` to align items along the cross axis within a single line. This
+behavior can be overridden by individual items using the
+[`alignSelf` modifier](https://developer.android.com/develop/ui/compose/layouts/adaptive/flexbox/item-behavior#item-alignment).
+
+The following images show the behavior when the direction is `Row`:
+
+|---|---|---|---|---|---|
+|  |  |  |  |  |  |
+| | `Start` | `End` | `Center` | `Stretch` | `Baseline` |
+
+Use `alignContent` to align lines to the cross axis and to distribute extra
+space between lines. This property only applies when there are multiple lines
+(wrapping is enabled). The following images show the behavior when the direction
+is `Row`:
+
+|---|---|---|---|---|---|---|
+|  |  |  |  |  |  |  |
+| | `Start` | `End` | `Center` | `Stretch` | `SpaceBetween` | `SpaceAround` |
+
+## Wrap items
+
+Wrapping lets a `FlexBox` container become multi-line, moving items that don't
+fit onto a new row or column along the cross-axis. Configure wrapping behavior
+using `wrap`.
+
+|---|---|
+| **`FlexWrap` value** | **Example using direction `Row`** |
+| `NoWrap` (default): Prevents items from wrapping. Items overflow if the main size is insufficient. |  |
+| `Wrap`: When there is insufficient space for an item (plus any [gap](https://developer.android.com/develop/ui/compose/layouts/adaptive/flexbox/container-behavior#add-gaps)), a new line is created in the direction of the cross axis. For example, if the direction is `Row`, a new line is added **below**. |  |
+| `WrapReverse`: The same as `Wrap`, except the new line is added in the opposite direction to the cross axis. For example, if the direction is `Row`, a new line is added **above**. |  |
+
+The following example shows how the `FlexBox` wrapping algorithm works. The
+`FlexBox` container has a main size of `100dp`, with `wrap` set to
+`FlexWrap.Wrap` and a gap of `8dp`. It contains three items with `basis` `20dp`,
+`40dp`, and `50dp`, respectively.
+
+There is `100dp` available space in the line. Child 1 is `20dp`.
+There is space, so Child 1 is placed into the line.
+ **Figure 1.** First item placed in the `FlexBox` container.
+
+There is `80dp` available space in the line. The gap is `8dp`. Child 2 is
+`40dp`. The required space is `48dp`. There is space, so the gap and Child 2
+are placed into the line.
+ **Figure 2.** Second item placed in the `FlexBox` container after the first item.
+
+There is `32dp` available space in the line. The gap is `8dp`. Child 3 is
+`50dp`. The required space is `58dp`. There is not enough space in the current
+line, so Child 3 is placed in a new line.
+ **Figure 3.** Third item placed on a new line because it doesn't fit on the first line.
+
+## Add gaps between items
+
+Add gaps between rows and columns using `rowGap` and `columnGap`. This is useful
+to avoid adding spacing modifiers to children.
+
+|---|---|---|
+|  |  |  |
+| `rowGap` adds vertical space between items and lines. | `columnGap` adds horizontal space between items and lines. | `gap` is a convenience function that adds both `columnGap` and `rowGap`. |
\ No newline at end of file
diff --git a/.claude/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/get-started.md b/.claude/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/get-started.md
new file mode 100644
index 00000000..8317e176
--- /dev/null
+++ b/.claude/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/get-started.md
@@ -0,0 +1,69 @@
+This page describes how to implement basic `FlexBox` layouts.
+
+## Set up project
+
+1. Add the [`androidx.compose.foundation.layout`](https://developer.android.com/jetpack/androidx/versions) library to your project's
+ `lib.versions.toml`.
+
+ [versions]
+ compose = "1.12.0-alpha03"
+
+ [libraries]
+ androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "compose" }
+
+2. Add the library dependency to your app's `build.gradle.kts`.
+
+ dependencies {
+ implementation(libs.androidx.compose.foundation.layout)
+ }
+
+## Create basic FlexBox layouts
+
+**Example 1** : `FlexBox` lays out two `Text` elements that are centrally
+aligned.
+
+
+```kotlin
+FlexBox(
+ config = {
+ direction(FlexDirection.Column)
+ alignItems(FlexAlignItems.Center)
+ }
+) {
+ Text(text = "Hello", fontSize = 48.sp)
+ Text(text = "World!", fontSize = 48.sp)
+}
+```
+
+
+
+
+
+**Example 2** : `FlexBox` wraps five items onto two rows and grows them unequally
+to fill the available space on each row. There is an `8.dp`
+gap, both vertically and horizontally, between the items.
+
+
+```kotlin
+FlexBox(
+ config = {
+ wrap(FlexWrap.Wrap)
+ gap(8.dp)
+ }
+) {
+ // All boxes have an intrinsic width of 100.dp
+ // Some grow to fill any remaining space on the row.
+ RedRoundedBox()
+ BlueRoundedBox()
+ GreenRoundedBox(modifier = Modifier.flex { grow(1.0f) })
+ OrangeRoundedBox(modifier = Modifier.flex { grow(1.0f) })
+ PinkRoundedBox(modifier = Modifier.flex { grow(1.0f) })
+}
+```
+
+
+
+
+
+To learn more about `FlexBox` behavior, see [Set container behavior](https://developer.android.com/develop/ui/compose/layouts/adaptive/flexbox/container-behavior) and [Set
+item behavior](https://developer.android.com/develop/ui/compose/layouts/adaptive/flexbox/item-behavior).
\ No newline at end of file
diff --git a/.claude/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/index.md b/.claude/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/index.md
new file mode 100644
index 00000000..ddda0deb
--- /dev/null
+++ b/.claude/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/index.md
@@ -0,0 +1,81 @@
+> [!NOTE]
+> **Note:** FlexBox is an experimental API and is likely to change in the future. To use it, annotate your code with `@ExperimentalFlexBoxApi`. Please file any issues or feedback on the [issue tracker](https://issuetracker.google.com/issues/new?component=1876021&title=%5BFlexBox%5D).
+
+[`FlexBox`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/FlexBox.composable#FlexBox(androidx.compose.ui.Modifier,androidx.compose.foundation.layout.FlexBoxConfig,kotlin.Function1)) is a container that lays out items in a single direction. It can
+resize, wrap, align, and distribute space among items to optimally fill the
+available space. It's a useful layout for different sized items and for resizing
+items when the available space changes.
+
+With `FlexBox`, you can:
+
+- Control how items grow and shrink to fill the available space
+- Wrap items onto new rows or columns when there isn't enough space for them
+- Distribute extra space between items using convenient presets
+
+## When to use FlexBox
+
+`FlexBox` is usually used to display a small number of items *within* an
+overall screen layout. For an overall screen layout,
+`Grid` is usually a better choice. `FlexBox` does not support lazy-loading of
+items. To display large numbers of items, use [lazy lists and grids](https://developer.android.com/develop/ui/compose/lists). If you
+need to wrap items, use `FlexBox` instead of `FlowRow` and `FlowColumn`.
+
+## Terminology and concepts
+
+> [!IMPORTANT]
+> **Key Point:** `FlexBox` is heavily influenced by the [CSS Flexible Box Layout specification](https://www.w3.org/TR/css-flexbox-1/) and has almost identical concepts, terminology, and behavior. If you're familiar with `display: flex`, you'll find `FlexBox`'s properties and behavior almost identical.
+
+`FlexBox` lays out its items in either horizontal or vertical *lines* . This
+direction of these lines establishes the *main axis* . 90 degrees to the main
+axis is the *cross axis* . The length of the `FlexBox` along the main axis is
+known as the *main size* . The corresponding cross axis length is known as the
+*cross size* . These sizes and axes form the basis of `FlexBox`'s behavior.
+
+
+ **Figure 1.** Axes and sizes when the `FlexBox` direction is `Row`.  **Figure 2.** Axes and sizes when the `FlexBox` direction is `Column`.
+
+
+
+### Apply properties
+
+You can apply `FlexBox` properties in two ways:
+
+- To the `FlexBox` container using `FlexBox(config)`
+- To an item inside the `FlexBox` using `Modifier.flex`
+
+| **Container properties (`config`**) | **Item properties (`Modifier.flex`**) |
+|---|---|
+| - `direction` - the item layout direction - `wrap` - whether to wrap items if the **main size** is insufficient - `justifyContent` - how to **distribute** items along the **main axis** - `alignItems` - how to **align** items along the **cross axis** - `alignContent` - how to distribute extra space from the **cross size** when there are multiple lines - `rowGap` / `columnGap` - adds space between items and lines See [Set container behavior](https://developer.android.com/develop/ui/compose/layouts/adaptive/flexbox/container-behavior) for more information about these properties. | - `basis` - the size of the item before any extra space from the **main size** is distributed - `grow` - the share of extra space from the **main size** that this item should receive - `shrink` - the share of space deficit from the **main size** that this item should receive - `alignSelf` - how to distribute extra space from the **cross size** to this item, overrides `alignItems` - `order` - controls the layout order See [Set item behavior](https://developer.android.com/develop/ui/compose/layouts/adaptive/flexbox/item-behavior) for more information about these properties. |
+
+### Understand the `FlexBox` layout algorithm
+
+One of `FlexBox`'s most powerful features is its ability to resize its children
+to best fit the space available to it. Understanding how `FlexBox` does this can
+help you set `FlexBox` properties to optimize your UI for all possible sizes.
+
+`FlexBox`'s layout algorithm works in the following way:
+
+1. **Calculate child base size** : Use the child's [`basis` value](https://developer.android.com/develop/ui/compose/layouts/adaptive/flexbox/item-behavior#set-initial-size)
+ to calculate its initial size along the main axis before any extra space is
+ distributed.
+
+2. **Sort the children** : Sort the children by their [`order`](https://developer.android.com/develop/ui/compose/layouts/adaptive/flexbox/item-behavior#item-order) values, if
+ present.
+
+3. **Build lines** : For each child, check if its initial size plus
+ [`gap`](https://developer.android.com/develop/ui/compose/layouts/adaptive/flexbox/container-behavior#add-gaps) will fit into the remaining space on the current line.
+ If so, place this child into the line. If not, place it onto a new line if
+ [wrapping is enabled](https://developer.android.com/develop/ui/compose/layouts/adaptive/flexbox/container-behavior#wrap-items), or place the item into the current line
+ where it will overflow (it will be partially obscured by the edge of the
+ container).
+
+4. **Align or resize items in the main axis** : For each line, distribute extra
+ space *to* or between items by [resizing](https://developer.android.com/develop/ui/compose/layouts/adaptive/flexbox/item-behavior#item-size) or
+ [aligning](https://developer.android.com/develop/ui/compose/layouts/adaptive/flexbox/container-behavior#main-axis) them.
+
+5. **Align or resize items in the cross axis** : For each line, distribute extra
+ space to or between items and lines by [stretching or aligning
+ them](https://developer.android.com/develop/ui/compose/layouts/adaptive/flexbox/container-behavior#cross-axis).
+
+Now that you're familiar with `FlexBox` concepts, see [Get started](https://developer.android.com/develop/ui/compose/layouts/adaptive/flexbox/get-started) to
+create a basic `FlexBox`.
\ No newline at end of file
diff --git a/.claude/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/item-behavior.md b/.claude/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/item-behavior.md
new file mode 100644
index 00000000..e4a5c0f5
--- /dev/null
+++ b/.claude/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/item-behavior.md
@@ -0,0 +1,170 @@
+Use `Modifier.flex` to control how an item changes size, order, and is aligned
+inside a `FlexBox`.
+
+## Item size
+
+Use the `basis`, `grow`, and `shrink` functions to control an item's size.
+
+
+```kotlin
+FlexBox {
+ RedRoundedBox(
+ modifier = Modifier.flex {
+ basis(FlexBasis.Auto)
+ grow(1.0f)
+ shrink(0.5f)
+ }
+ )
+}
+```
+
+
+
+### Set initial size
+
+Use `basis` to specify the item's initial size before any extra space is
+distributed. You can think of this as the item's *preferred* size.
+
+|---|---|---|---|
+| **Value type** | **Behavior** | **Code snippet** Note: The boxes have a maximum intrinsic size of `100dp` | **Example using container width `600dp`** |
+| `Auto` (default) | Use the item's maximum intrinsic size. For example, a `Text` composable's maximum intrinsic width is the width of all its text on a single line - no wrapping. | ```kotlin FlexBox { RedRoundedBox( Modifier.flex { basis(FlexBasis.Auto) } ) BlueRoundedBox( Modifier.flex { basis(FlexBasis.Auto) } ) } ``` |  |
+| Fixed `dp` | A fixed size in Dp. | ```kotlin FlexBox { RedRoundedBox( Modifier.flex { basis(200.dp) } ) BlueRoundedBox( Modifier.flex { basis(100.dp) } ) } ``` |  |
+| Percentage | A percentage of the container size. | ```kotlin FlexBox { RedRoundedBox( Modifier.flex { basis(0.7f) } ) BlueRoundedBox( Modifier.flex { basis(0.3f) } ) } ``` |  |
+
+If the basis value is less than the item's intrinsic minimum size, the intrinsic
+minimum size is used instead. For example, if a `Text` item that contains a word
+requires `50dp` to display, but also has `basis = 10.dp`, a
+value of `50dp` is used.
+
+### Grow items when there's space
+
+Use `grow` to specify how much an item grows when there is extra space. This is
+space remaining in the `FlexBox` container after all the items' `basis` values
+have been added up. The `grow` value indicates *how much* of the extra space a
+given child will receive, relative to its siblings. By default, items won't
+grow.
+
+The following example shows a `FlexBox` with three child items. Each has a basis
+value of `100dp`. The first child has a positive `grow` value. Since there is
+only one child with a `grow` value, the actual value is irrelevant - as long as
+it's positive, the child receives all the extra space.
+
+The images show the `FlexBox` behavior when its container size is `600dp`.
+
+|---|---|
+| ```kotlin FlexBox { RedRoundedBox( title = "400dp", modifier = Modifier.flex { grow(1f) } ) BlueRoundedBox(title = "100dp") GreenRoundedBox(title = "100dp") } ``` | Each child has a basis value of `100dp`. There is `300dp` of extra space.  Child 1 grows by `300dp` to fill the extra space.  |
+
+In the following example, the container size and `basis` size are the same. The
+difference is that each child has a different `grow` value.
+
+|---|---|
+| ```kotlin FlexBox { RedRoundedBox( title = "150dp", modifier = Modifier.flex { grow(1f) } ) BlueRoundedBox( title = "200dp", modifier = Modifier.flex { grow(2f) } ) GreenRoundedBox( title = "250dp", modifier = Modifier.flex { grow(3f) } ) } ``` | Each child has a basis value of `100dp`. There is `300dp` of extra space.  The total grow value is 6. Child 1 grows by (1 / 6) \* 300 = `50dp` Child 2 grows by (2 / 6) \* 300 = `100dp` Child 3 grows by (3 / 6) \* 300 = `150dp`  |
+
+### Shrink items when there's insufficient space
+
+Use `shrink` to specify how much an item shrinks when the `FlexBox` container
+has insufficient space for all the items. `shrink` works the same way as `grow`
+except that, instead of distributing *extra space* to items, the *space deficit*
+is distributed to items. The `shrink` value specifies how much of the space
+deficit the item receives, or rather, how much the item will shrink by. By
+default, items have a `shrink` value of `1f`, meaning they shrink equally.
+
+The following example shows two `Text` composables with the same text. The first
+child has a shrink value of `1f`, meaning it shrinks to absorb all the space
+deficit.
+
+
+```kotlin
+FlexBox {
+ Text(
+ "The quick brown fox",
+ fontSize = 36.sp,
+ modifier = Modifier
+ .background(PastelRed)
+ .flex { shrink(1f) }
+ )
+ Text(
+ "The quick brown fox",
+ fontSize = 36.sp,
+ modifier = Modifier
+ .background(PastelBlue)
+ .flex { shrink(0f) }
+ )
+}
+```
+
+
+
+As the container size shrinks, Child 1 shrinks.
+
+|---|---|
+| **Container size** | **FlexBox UI** |
+| `700dp` |  |
+| `500dp` |  |
+| `450dp` |  |
+
+## Item alignment
+
+Use `alignSelf` to control how an item is aligned to the cross axis. This
+overrides the [`alignItems` property](https://developer.android.com/develop/ui/compose/layouts/adaptive/flexbox/container-behavior#align-distribute) of the container for this item. It
+has all the same possible values, with the addition of `Auto` which inherits the
+behavior of the `FlexBox` container.
+
+For example, this `FlexBox` has `alignItems` set to `Start` and five children
+which override the cross axis alignment.
+
+
+```kotlin
+FlexBox(
+ config = {
+ alignItems(FlexAlignItems.Start)
+ }
+) {
+ RedRoundedBox()
+ BlueRoundedBox(modifier = Modifier.flex { alignSelf(FlexAlignSelf.Center) })
+ GreenRoundedBox(modifier = Modifier.flex { alignSelf(FlexAlignSelf.End) })
+ PinkRoundedBox(modifier = Modifier.flex { alignSelf(FlexAlignSelf.Stretch) })
+ OrangeRoundedBox(modifier = Modifier.flex { alignSelf(FlexAlignSelf.Baseline) })
+}
+```
+
+
+
+
+
+## Item order
+
+By default, `FlexBox` lays out items in the order that they are declared in
+code. Override this behavior using `order`.
+
+The default value for `order` is zero, and `FlexBox` sorts items based on this
+value in ascending order. Any items that have the same `order` value are
+laid out in the same order they are declared in. Use negative and positive
+`order` values to move items to the start or end of a layout without changing
+where they are declared.
+
+The following example shows two child items. The first has the default `order`
+of zero, and the second has an order of `-1`. After sorting, Child 1 appears
+after Child 2.
+
+
+```kotlin
+FlexBox {
+ // Declared first, but will be placed after visually
+ RedRoundedBox(
+ title = "World"
+ )
+
+ // Declared second, but will be placed first visually
+ BlueRoundedBox(
+ title = "Hello",
+ modifier = Modifier.flex {
+ order(-1)
+ }
+ )
+}
+```
+
+
+
+
\ No newline at end of file
diff --git a/.claude/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/container-properties.md b/.claude/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/container-properties.md
new file mode 100644
index 00000000..63d160c9
--- /dev/null
+++ b/.claude/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/container-properties.md
@@ -0,0 +1,238 @@
+You can define a Grid container configuration to create flexible layouts
+that respond to different screen sizes and content types.
+This page describes how to do the following:
+
+- [Define a grid](https://developer.android.com/develop/ui/compose/layouts/adaptive/grid/container-properties#grid-definition): Set up the basic structure of rows and columns.
+- [Place items in a grid](https://developer.android.com/develop/ui/compose/layouts/adaptive/grid/container-properties#item-placement): Understand how items are placed into grid cells and how to change flow direction.
+- [Manage track sizing](https://developer.android.com/develop/ui/compose/layouts/adaptive/grid/container-properties#grid-track-size): Use fixed, percentage, flexible, and intrinsic sizing to set track sizes.
+- [Set gaps](https://developer.android.com/develop/ui/compose/layouts/adaptive/grid/container-properties#grid-gap): Manage the "gutters" between rows and columns.
+
+## Define a grid
+
+A grid consists of columns and rows.
+The [`Grid`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/Grid.composable#Grid(kotlin.Function1,androidx.compose.ui.Modifier,kotlin.Function1)) composable has a `config` parameter
+that accepts a lambda to define the columns and rows
+within [`GridConfigurationScope`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/GridConfigurationScope).
+The following example defines a grid that has three rows and two columns,
+each with a fixed size specified in [`Dp`](https://developer.android.com/reference/kotlin/androidx/compose/ui/unit/Dp):
+
+
+```kotlin
+Grid(
+ config = {
+ repeat(2) {
+ column(160.dp)
+ }
+ repeat(3) {
+ row(90.dp)
+ }
+ }
+) {
+}
+```
+
+
+
+## Place items in a grid
+
+`Grid` takes the UI elements
+in the `content` lambda and places them into grid cells.
+The grid lays out items regardless of
+whether you have explicitly defined the rows and columns.
+By default,
+`Grid` tries to place a UI element in the available grid cell in the row;
+if it can't, it places it in an available grid cell in the next row.
+If there are no empty cells, `Grid` creates a new row.
+
+In the following example, the grid has six grid cells
+and places a card into each one (Figure 1).
+Each grid cell is `160dp` x `90dp`,
+making the total grid size `320dp` x `270dp`.
+
+
+```kotlin
+Grid(
+ config = {
+ repeat(2) {
+ column(160.dp)
+ }
+ repeat(3) {
+ row(90.dp)
+ }
+ }
+) {
+ Card1()
+ Card2()
+ Card3()
+ Card4()
+ Card5()
+ Card6()
+}
+```
+
+
+
+ **Figure 1**. Six cards are placed in a grid that has three rows and two columns.
+
+To change this default behavior to filling by column,
+set the [`flow`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/GridConfigurationScope#flow()) property to [`GridFlow.Column`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/GridFlow#Column()).
+
+
+```kotlin
+Grid(
+ config = {
+ repeat(2) {
+ column(160.dp)
+ }
+ repeat(3) {
+ row(90.dp)
+ }
+ gap(8.dp)
+ flow = GridFlow.Column // Grid tries to place items to fill the column
+ },
+) {
+ Card1()
+ Card2()
+ Card3()
+ Card4()
+ Card5()
+ Card6()
+}
+```
+
+
+
+ **Figure 2** . `GridFlow.Row` (left) and `GridFlow.Column` (right).
+
+## Manage track sizing
+
+Rows and columns are collectively referred to as a [grid track](https://developer.android.com/develop/ui/compose/layouts/adaptive/grid#grid-track).
+You can specify the size of a grid track using one of the following methods:
+
+- **Fixed** (`Dp`): Allocates a specific size (e.g., `column(180.dp)`).
+- **Percentage** (`Float`): Allocates a percentage of the total available space from `0.0f` to `1.0f` (e.g., `row(0.5f)` for 50%).
+- **Flexible** ([`Fr`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/Fr)): Distributes remaining space proportionally after fixed and percentage tracks are calculated. For example, if two rows are set to `1.fr` and `3.fr`, the latter receives 75% of the remaining height.
+- **Intrinsic** : Sizes the track based on the content inside it. For more information, see [Determine grid track size intrinsically](https://developer.android.com/develop/ui/compose/layouts/adaptive/grid/container-properties#intrisic-grid-track-size).
+
+The following example uses the different track sizing options
+to define the row heights:
+
+
+```kotlin
+Grid(
+ config = {
+ column(1f)
+
+ row(100.dp)
+ row(0.2f)
+ row(1.fr)
+ row(GridTrackSize.Auto)
+ },
+ modifier = Modifier.height(480.dp)
+) {
+ PastelRedCard("Fixed(100.dp)")
+```
+
+
+
+ **Figure 3** . Row heights defined using the four primary track sizing options in `Grid`.
+
+### Determine grid track size intrinsically
+
+You can use [intrinsic sizing](https://developer.android.com/develop/ui/compose/layouts/intrinsic-measurements) for a `Grid`
+when you want the layout to adapt to the content,
+rather than forcing it into a fixed container.
+The grid track size is determined with the following values:
+
+- [`GridTrackSize.MaxContent`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/GridTrackSize#MaxContent()): Use the content's maximum intrinsic size (e.g., the width is determined by the full length of the text in a text block with no wrapping).
+- [`GridTrackSize.MinContent`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/GridTrackSize#MinContent()): Use the content's minimum intrinsic size (e.g., the width is determined by the longest single word in a text block).
+- [`GridTrackSize.Auto`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/GridTrackSize#Auto()): Use a flexible size for a track that adapts based on available space. It behaves like `MaxContent` by default, but shrinks and wraps its content to fit within the parent container.
+
+The following example places two texts side by side.
+The column size for the first text is determined
+by the required minimum width to display the text,
+and the second column width depends on the required maximum width of the text.
+
+
+```kotlin
+Grid(
+ config = {
+ column(GridTrackSize.MinContent)
+ column(GridTrackSize.MaxContent)
+ row(1.0f)
+ },
+ modifier = Modifier.width(480.dp)
+) {
+ Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras imperdiet." )
+ Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras imperdiet." )
+}
+```
+
+
+
+ **Figure 4**. Intrinsic sizes specified in the columns.
+
+## Set gaps between rows and columns
+
+Once your grid tracks are sized,
+you can modify the [grid gap](https://developer.android.com/develop/ui/compose/layouts/adaptive/grid#grid-gap) to refine the spacing between the tracks.
+You can specify the column gap with the [`columnGap`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/GridConfigurationScope#columnGap(androidx.compose.ui.unit.Dp)) function,
+and the row gap with [`rowGap`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/GridConfigurationScope#rowGap(androidx.compose.ui.unit.Dp)). In the following example,
+there is a `16dp` gap between each row,
+and an `8dp` gap between each column (Figure 5).
+
+
+```kotlin
+Grid(
+ config = {
+ repeat(2) {
+ column(160.dp)
+ }
+ repeat(3) {
+ row(90.dp)
+ }
+ rowGap(16.dp)
+ columnGap(8.dp)
+ }
+) {
+ Card1()
+ Card2()
+ Card3()
+ Card4()
+ Card5()
+ Card6()
+}
+```
+
+
+
+ **Figure 5**. Gaps between rows and columns.
+
+You can also use the convenience function [`gap`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/GridConfigurationScope#gap(androidx.compose.ui.unit.Dp))
+to define gaps of the same column and row size,
+and to define column and gap sizes separately using a single function.
+The following code adds `8dp` gaps to the grid:
+
+
+```kotlin
+Grid(
+ config = {
+ repeat(2) {
+ column(160.dp)
+ }
+ repeat(3) {
+ row(90.dp)
+ }
+ gap(8.dp) // Equivalent to columnGap(8.dp) and rowGap(8.dp)
+ }
+) {
+ Card1()
+ Card2()
+ Card3()
+ Card4()
+ Card5()
+ Card6()
+}
+```
+
+
\ No newline at end of file
diff --git a/.claude/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/get-started.md b/.claude/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/get-started.md
new file mode 100644
index 00000000..28b8c3d5
--- /dev/null
+++ b/.claude/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/get-started.md
@@ -0,0 +1,51 @@
+This page describes how to implement basic [`Grid`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/Grid.composable#Grid(kotlin.Function1,androidx.compose.ui.Modifier,kotlin.Function1)) layouts.
+
+## Set up project
+
+1. Add the [`androidx.compose.foundation.layout`](https://developer.android.com/jetpack/androidx/versions) library to your project's
+ `lib.versions.toml`.
+
+ [versions]
+ compose = "1.12.0-alpha03"
+
+ [libraries]
+ androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "compose" }
+
+2. Add the library dependency to your app's `build.gradle.kts`.
+
+ dependencies {
+ implementation(libs.androidx.compose.foundation.layout)
+ }
+
+## Create a basic grid
+
+The following example creates a basic 2x3 grid,
+with the columns and rows having a fixed size of `100.dp`.
+
+
+```kotlin
+Grid(
+ config = {
+ repeat(2) {
+ column(100.dp)
+ }
+ repeat(3) {
+ row(100.dp)
+ }
+ }
+) {
+ Card1(containerColor = PastelRed)
+ Card2(containerColor = PastelGreen)
+ Card3(containerColor = PastelBlue)
+ Card4(containerColor = PastelPink)
+ Card5(containerColor = PastelOrange)
+ Card6(containerColor = PastelYellow)
+}
+```
+
+
+
+ **Figure 1**. A basic grid consists of rows and columns with fixed size.
+
+To learn how to implement more advanced grids,
+see [Set container properties](https://developer.android.com/develop/ui/compose/layouts/adaptive/grid/container-properties) and [Set item properties](https://developer.android.com/develop/ui/compose/layouts/adaptive/grid/item-properties).
\ No newline at end of file
diff --git a/.claude/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/index.md b/.claude/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/index.md
new file mode 100644
index 00000000..1f74aee7
--- /dev/null
+++ b/.claude/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/index.md
@@ -0,0 +1,73 @@
+> [!NOTE]
+> **Note:** `Grid` is an experimental API and is subject to change. File any issues on the [issue tracker](https://issuetracker.google.com/issues/new?component=1876021&template=1424126).
+
+[`Grid`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/Grid.composable#Grid(kotlin.Function1,androidx.compose.ui.Modifier,kotlin.Function1)) is a Jetpack Compose API
+that lets you flexibly implement a two-dimensional layout.
+With this API, you can display items in multi-column
+or multi-row layouts that adapt to the available container size.
+ **Figure 1.** A flexible and adaptive two-dimensional layout with `Grid`.
+
+## How is Grid different from similar composables?
+
+Compose already offers similar components, such as [`LazyVerticalGrid`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/lazy/grid/LazyVerticalGrid.composable#LazyVerticalGrid(androidx.compose.foundation.lazy.grid.GridCells,androidx.compose.ui.Modifier,androidx.compose.foundation.lazy.grid.LazyGridState,androidx.compose.foundation.layout.PaddingValues,kotlin.Boolean,androidx.compose.foundation.layout.Arrangement.Vertical,androidx.compose.foundation.layout.Arrangement.Horizontal,androidx.compose.foundation.gestures.FlingBehavior,kotlin.Boolean,androidx.compose.foundation.OverscrollEffect,kotlin.Function1)).
+These components are mainly for visualization of large, homogeneous data sets---
+for example, displaying a content catalog in a video streaming app.
+These components are NOT designed
+for the structural layout of a screen or complex component.
+
+You can also implement a two-dimensional layout
+by combining multiple `Row` and `Column` composables.
+However, this approach has some downsides,
+such as deep hierarchies and difficulties in adaptability.
+
+The following table provides an overview
+of which layouts are suitable for each API:
+
+| Component | Purpose |
+|---|---|
+| `LazyVerticalGrid`, `LazyStaggeredGrid`, `LazyHorizontalGrid` | Visualization of large, homogeneous data sets that requires lazy loading. |
+| `Row`, `Column`, `FlexBox` | One-dimensional layout |
+| `Grid` | Two-dimensional layout |
+
+> [!NOTE]
+> **Note:** `Grid` doesn't support lazy loading.
+
+## Terminology
+
+Familiarize yourself with the following terminology
+to understand how `Grid` works.
+
+### Grid line
+
+A grid is made up of lines, which run horizontally and vertically.
+If your grid has three rows, it has four horizontal lines,
+including the one after the last row.
+In the following image, each dotted line represents a grid line:
+ **Figure 2**. The grid consists of four horizontal lines and three vertical lines.
+
+### Grid track
+
+A grid track is the space between two grid lines.
+A row track is between two horizontal lines,
+and a column track is between two vertical lines.
+To define the size of these tracks,
+assign a size to them when you create the grid.
+ **Figure 3**. A grid track for the first row.
+
+### Grid cell
+
+A grid cell is the intersection of a row and column track.
+ **Figure 4**. A grid cell that is an intersection of the second row and the second column.
+
+### Grid area
+
+A grid area consists of several grid cells.
+You can define a grid area by making an item span multiple tracks.
+ **Figure 5**. A grid area that consists of four grid cells.
+
+### Grid gap
+
+A grid gap is the gutter between grid tracks.
+You can't place a UI element into a gap,
+but you can span a UI element across it.
+ **Figure 6**. A grid gap between the first column and the second column.
\ No newline at end of file
diff --git a/.claude/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/item-properties.md b/.claude/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/item-properties.md
new file mode 100644
index 00000000..f5ecc87c
--- /dev/null
+++ b/.claude/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/item-properties.md
@@ -0,0 +1,168 @@
+While the `Grid` config defines the overall structure,
+you use the [`gridItem`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/GridScope#(androidx.compose.ui.Modifier).gridItem(kotlin.Int,kotlin.Int,kotlin.Int,kotlin.Int,androidx.compose.ui.Alignment)) modifier to control the position, spanning,
+and alignment of items within that structure.
+
+## Set the item position
+
+Place an item into a specific track or cell
+with the `row` and `column` parameters.
+
+The `row` and `column` parameters specify the row and column track indexes
+that the item is placed in.
+Track indexes are 1-based---they start at one.
+Specifying only `row` or `column` (not both) places the item
+in the next available space in that track.
+Specifying both places the item into that cell.
+
+Use a positive integer to specify the track index from the start.
+For example, to place an item in the first row and column,
+use `gridItem(row = 1, column = 1)`.
+
+Use a negative integer to specify the track relative to the end.
+For example, to place an item in the second-to-last row and column, use
+`gridItem(row = -2, column = -2)`.
+
+In the following example, Card **#2** is placed
+in the second row and the second column.
+Card **#3** is assigned to the last row (indexed by -1),
+where it automatically occupies
+the first available column in that track (Figure 1).
+
+
+```kotlin
+Grid(
+ config = {
+ repeat(2) {
+ column(160.dp)
+ }
+ repeat(3) {
+ row(90.dp)
+ }
+ gap(8.dp)
+ }
+) {
+ Card1()
+ Card2(modifier = Modifier.gridItem(row = 2, column = 2))
+ Card3(modifier = Modifier.gridItem(row = -1, column = -2))
+}
+```
+
+
+
+ **Figure 1** . Card **#2** is placed in the grid cell in the second row and the second column, and Card **#3** is placed in the first column in the third row.
+
+## Span rows and columns
+
+Use the `rowSpan` and `columnSpan` parameters
+to span an item over multiple cells.
+You can place a UI element into a [grid area](https://developer.android.com/develop/ui/compose/layouts/adaptive/grid#grid-area),
+which is the area consisting of several [grid cells](https://developer.android.com/develop/ui/compose/layouts/adaptive/grid#grid-cell).
+The `gridItem` modifier lets you specify the grid area
+with the `rowSpan` and `columnSpan` parameters.
+In the following example,
+Card **#1** is placed in the area consisting of two rows and two columns
+(Figure 2).
+
+
+```kotlin
+Grid(
+ config = {
+ repeat(3) {
+ column(160.dp)
+ }
+ repeat(3) {
+ row(90.dp)
+ }
+ rowGap(8.dp)
+ columnGap(8.dp)
+ }
+) {
+ Card1(modifier = Modifier.gridItem(rowSpan = 2, columnSpan = 2))
+ Card2()
+ Card3()
+ Card4(modifier = Modifier.gridItem(columnSpan = 3))
+}
+```
+
+
+
+ **Figure 2** . Card **#4** spans three columns.
+
+## Set the alignment in a grid area
+
+You can set the alignment of the UI element in a grid area
+by specifying it in the `alignment` parameter of the [`gridItem`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/GridScope#(androidx.compose.ui.Modifier).gridItem(kotlin.Int,kotlin.Int,kotlin.Int,kotlin.Int,androidx.compose.ui.Alignment)) modifier.
+In the following example, **#1** is placed in the center of the grid area
+consisting of two columns and two rows.
+
+
+```kotlin
+Grid(
+ config = {
+ repeat(3) {
+ column(160.dp)
+ }
+ repeat(3) {
+ row(90.dp)
+ }
+ rowGap(8.dp)
+ columnGap(8.dp)
+ },
+) {
+ Text(
+ text = "#1",
+ modifier = Modifier
+ .gridItem(
+ rowSpan = 2,
+ columnSpan = 2,
+ alignment = Alignment.Center
+ ),
+ )
+ Card2()
+ Card3()
+ Card4(modifier = Modifier.gridItem(columnSpan = 3))
+}
+```
+
+
+
+ **Figure 3** . The Text with **#1** is placed in the center of the grid area consisting of two rows and two columns.
+
+## Auto-placement mixed with placed items
+
+A UI element in `Grid`
+that has no position specification undergoes auto-placement.
+This example shows how you can mix auto-placed elements
+and the UI elements with specified grid cells.
+Card **#2** and card **#4** are specified grid cells,
+and the other items are auto-placed.
+
+
+```kotlin
+Grid(
+ config = {
+ repeat(2) {
+ column(160.dp)
+ }
+ repeat(3) {
+ row(90.dp)
+ }
+ rowGap(16.dp)
+ columnGap(8.dp)
+ }
+) {
+ Card1()
+ Card2(modifier = Modifier.gridItem(row = 2, column = 2))
+ Card3()
+ Card4(modifier = Modifier.gridItem(row = 3, column = 1))
+ Card5()
+ Card6()
+}
+```
+
+
+
+ **Figure 4** . Card **#3** is placed next to card **#1**, as it is an auto-placement.
\ No newline at end of file
diff --git a/.claude/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/mediaquery/index.md b/.claude/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/mediaquery/index.md
new file mode 100644
index 00000000..4cd25648
--- /dev/null
+++ b/.claude/skills/adaptive/references/android/develop/ui/compose/layouts/adaptive/mediaquery/index.md
@@ -0,0 +1,329 @@
+> [!NOTE]
+> **Note:** The `mediaQuery` function and the related data types are experimental and subject to change. File any issues on the [issue tracker](https://issuetracker.google.com/issues?q=componentid:1876021).
+
+You need various types of information, such as device capability
+and app status, to update your app layout.
+Window width and height are the most commonly used information.
+In addition to that, you can refer to the following information:
+
+- Window posture
+- Pointing devices precision
+- Keyboard type
+- Whether the camera and microphone are supported by the device
+- The distance between a user and the device display
+
+Because the information is updated dynamically,
+you need to monitor it and trigger recomposition when any update happens.
+The [`mediaQuery`](https://developer.android.com/reference/kotlin/androidx/compose/ui/mediaQuery.composable#mediaQuery(kotlin.Function1)) function abstracts the details of the information retrieval
+and lets you focus on defining the condition to trigger the layout updates.
+The following example switches the layout to `TabletopLayout`
+when the foldable posture is tabletop:
+
+
+```kotlin
+@Composable
+fun VideoPlayer(
+ // ...
+) {
+ // ...
+ if (mediaQuery { windowPosture == UiMediaScope.Posture.Tabletop }) {
+ TabletopLayout()
+ } else {
+ FlatLayout()
+ }
+ // ...
+}
+```
+
+
+
+## Enable the `mediaQuery` function
+
+To enable the `mediaQuery` function,
+set the `isMediaQueryIntegrationEnabled` attribute of
+the [`ComposeUiFlags`](https://developer.android.com/reference/kotlin/androidx/compose/ui/ComposeUiFlags) object to `true`:
+
+
+```kotlin
+class MyApplication : Application() {
+ override fun onCreate() {
+ ComposeUiFlags.isMediaQueryIntegrationEnabled = true
+ super.onCreate()
+ }
+}
+```
+
+
+
+## Define a condition with parameters
+
+You can define a condition as a lambda
+that is evaluated within [`UiMediaScope`](https://developer.android.com/reference/kotlin/androidx/compose/ui/UiMediaScope).
+The `mediaQuery` function evaluates the condition according to
+the current status and the device capabilities.
+The function returns a boolean value,
+so you can determine the layout with conditional branches
+like an `if` expression.
+Table 1 describes the parameters available in `UiMediaScope`.
+
+| Parameter | Value type | Description |
+|---|---|---|
+| `windowWidth` | [`Dp`](https://developer.android.com/reference/kotlin/androidx/compose/ui/unit/Dp) | The current window width in dp. |
+| `windowHeight` | `Dp` | The current window height in dp. |
+| `windowPosture` | [`UiMediaScope.Posture`](https://developer.android.com/reference/kotlin/androidx/compose/ui/UiMediaScope.Posture) | The current posture of the application window. |
+| `pointerPrecision` | [`UiMediaScope.PointerPrecision`](https://developer.android.com/reference/kotlin/androidx/compose/ui/UiMediaScope.PointerPrecision) | The highest precision of the available pointing devices. |
+| `keyboardKind` | [`UiMediaScope.KeyboardKind`](https://developer.android.com/reference/kotlin/androidx/compose/ui/UiMediaScope.KeyboardKind) | The type of keyboard available or connected. |
+| `hasCamera` | `Boolean` | Whether the camera is supported on the device. |
+| `hasMicrophone` | `Boolean` | Whether the microphone is supported on the device. |
+| `viewingDistance` | [`UiMediaScope.ViewingDistance`](https://developer.android.com/reference/kotlin/androidx/compose/ui/UiMediaScope.ViewingDistance) | The typical distance between the user and the device screen. |
+
+A `UiMediaScope` object resolves the values of the parameters.
+The `mediaQuery` function uses [`LocalUiMediaScope.current`](https://developer.android.com/reference/kotlin/androidx/compose/ui/package-summary#LocalUiMediaScope())
+to access the `UiMediaScope` object,
+which represents the current device capabilities and context.
+This object is dynamically updated when any changes are made,
+such as when the user changes the device posture.
+The `mediaQuery` function then evaluates the `query` lambda
+with the updated `UiMediaScope` object and returns a boolean value.
+For example, the following snippet chooses between `TabletopLayout`
+and `FlatLayout` based on the `windowPosture` parameter value.
+
+
+```kotlin
+@Composable
+fun VideoPlayer(
+ // ...
+) {
+ // ...
+ if (mediaQuery { windowPosture == UiMediaScope.Posture.Tabletop }) {
+ TabletopLayout()
+ } else {
+ FlatLayout()
+ }
+ // ...
+}
+```
+
+
+
+### Make a decision based on the window size
+
+[Window size classes](https://developer.android.com/develop/ui/compose/layouts/adaptive/use-window-size-classes) are a set of opinionated viewport breakpoints
+that help you design, develop, and test adaptive layouts.
+You can compare the two parameters representing the current window size
+with the threshold defined in the window size classes.
+The following example changes the number of panes according to the window width.
+[`WindowSizeClass`](https://developer.android.com/reference/androidx/window/core/layout/WindowSizeClass) class has constants for the thresholds
+of window size classes (Figure 1).
+
+The [`derivedMediaQuery`](https://developer.android.com/reference/kotlin/androidx/compose/ui/derivedMediaQuery.composable#derivedMediaQuery(kotlin.Function1)) function evaluates the `query` lambda
+and wraps the result in a [`derivedStateOf`](https://developer.android.com/develop/ui/compose/side-effects#derivedstateof).
+Because `windowWidth` and `windowHeight` can update frequently,
+call the `derivedMediaQuery` function instead of the `mediaQuery` function
+when you refer to those parameters in the `query` lambda.
+
+
+```kotlin
+val narrowerThanMedium by derivedMediaQuery {
+ windowWidth < WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND.dp
+}
+val narrowerThanExpanded by derivedMediaQuery {
+ windowWidth < WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND.dp
+}
+when {
+ narrowerThanMedium -> SinglePaneLayout()
+ narrowerThanExpanded -> TwoPaneLayout()
+ else -> ThreePaneLayout()
+}
+```
+
+
+
+**Figure 1**. Layout is updated according to the window width.
+
+### Update layout according to the window posture
+
+The `windowPosture` parameter describes the current window posture
+as a `UiMediaScope.Posture` object.
+You can check the current [posture](https://developer.android.com/develop/ui/compose/layouts/adaptive/foldables/learn-about-foldables) by comparing the parameter
+with the values defined in the `UiMediaScope.Posture` class.
+The following example switches layout according to the window posture:
+
+
+```kotlin
+when {
+ mediaQuery { windowPosture == UiMediaScope.Posture.Tabletop } -> TabletopLayout()
+ mediaQuery { windowPosture == UiMediaScope.Posture.Book } -> BookLayout()
+ mediaQuery { windowPosture == UiMediaScope.Posture.Flat } -> FlatLayout()
+}
+```
+
+
+
+### Check the precision of the available pointing device
+
+A high precision pointing device helps users to point a UI element precisely.
+The precision of a pointing device depends on the device type.
+
+The `pointerPrecision` parameter describes the precision
+of the available pointing devices, such as a mouse and touchscreen.
+There are four values defined in the `UiMediaScope.PointerPrecision` class:
+[`Fine`](https://developer.android.com/reference/kotlin/androidx/compose/ui/UiMediaScope.PointerPrecision#Fine()), [`Coarse`](https://developer.android.com/reference/kotlin/androidx/compose/ui/UiMediaScope.PointerPrecision#Coarse()), [`Blunt`](https://developer.android.com/reference/kotlin/androidx/compose/ui/UiMediaScope.PointerPrecision#Blunt()), and [`None`](https://developer.android.com/reference/kotlin/androidx/compose/ui/UiMediaScope.PointerPrecision#None()).
+`None` means that no pointing device is available.
+The precision ranges from highest to lowest in this order:
+`Fine`, `Coarse`, and `Blunt`.
+
+If multiple pointing devices are available and their precisions are different,
+the parameter is resolved with the highest one.
+For example, if there are two pointing devices --- a `Fine` precision device and
+a `Blunt` precision device ---
+`Fine` is the value of the `pointerPrecision` parameter.
+
+The following example shows a larger button
+when the user is using a pointing device with low precision:
+
+
+```kotlin
+if (mediaQuery { pointerPrecision == UiMediaScope.PointerPrecision.Blunt }) {
+ LargeSizeButton()
+} else {
+ NormalSizeButton()
+}
+```
+
+
+
+### Check the available keyboard type
+
+The `keyboardKind` parameter represents the type of the available keyboards:
+[`Physical`](https://developer.android.com/reference/kotlin/androidx/compose/ui/UiMediaScope.KeyboardKind#Physical()), [`Virtual`](https://developer.android.com/reference/kotlin/androidx/compose/ui/UiMediaScope.KeyboardKind#Virtual()), and [`None`](https://developer.android.com/reference/kotlin/androidx/compose/ui/UiMediaScope.KeyboardKind#None()).
+If an on-screen keyboard is displayed and
+a hardware keyboard is available at the same time,
+the parameter is resolved as `Physical`.
+If neither is detected, `None` is the value of the parameter.
+The following example shows a message suggesting that users connect a keyboard
+when no keyboard is detected:
+
+
+```kotlin
+if (mediaQuery { keyboardKind == UiMediaScope.KeyboardKind.None }) {
+ SuggestKeyboardConnect()
+}
+```
+
+
+
+### Check if the device supports camera and microphone
+
+Some devices don't support cameras or microphones.
+You can check if the device supports a camera and a microphone
+with the `hasCamera` parameter and the `hasMicrophone` parameter.
+The following example shows buttons to use with camera and microphone
+when the device supports them:
+
+
+```kotlin
+Row {
+ OutlinedTextField(state = rememberTextFieldState())
+ // Show the MicButton when the device supports a microphone.
+ if (mediaQuery { hasMicrophone }) {
+ MicButton()
+ }
+ // Show the CameraButton when the device supports a camera.
+ if (mediaQuery { hasCamera }) {
+ CameraButton()
+ }
+}
+```
+
+
+
+### Adjust UI with the estimated viewing distance
+
+Viewing distance is a factor that helps determine layout.
+If the user is using the app from a distance,
+they would expect the text and UI elements to be bigger.
+The `viewingDistance` parameter provides an estimate of the viewing distance
+based on the device type and its typical usage context.
+
+There are three values defined in the `UiMediaScope.ViewingDistance` class:
+[`Near`](https://developer.android.com/reference/kotlin/androidx/compose/ui/UiMediaScope.ViewingDistance#Near()), [`Medium`](https://developer.android.com/reference/kotlin/androidx/compose/ui/UiMediaScope.ViewingDistance#Medium()), and [`Far`](https://developer.android.com/reference/kotlin/androidx/compose/ui/UiMediaScope.ViewingDistance#Far()).
+`Near` means that the screen is in close range,
+and `Far` means that the device is viewed from a distance.
+The following example increases the font size when the viewing distance is
+`Far` or `Medium`:
+
+
+```kotlin
+val fontSize = when {
+ mediaQuery { viewingDistance == UiMediaScope.ViewingDistance.Far } -> 20.sp
+ mediaQuery { viewingDistance == UiMediaScope.ViewingDistance.Medium } -> 18.sp
+ else -> 16.sp
+}
+```
+
+
+
+## Preview a UI component
+
+You can call the `mediaQuery` and `derivedMediaQuery` functions in the
+composable functions to preview UI components.
+The following snippet chooses between `TabletopLayout`
+and `FlatLayout` based on the `windowPosture` parameter value.
+To preview the `TabletopLayout`, the `windowPosture` parameter should be
+[`UiMediaScope.Posture.Tabletop`](https://developer.android.com/reference/kotlin/androidx/compose/ui/UiMediaScope.Posture#Tabletop()).
+
+
+```kotlin
+when {
+ mediaQuery { windowPosture == UiMediaScope.Posture.Tabletop } -> TabletopLayout()
+ mediaQuery { windowPosture == UiMediaScope.Posture.Book } -> BookLayout()
+ mediaQuery { windowPosture == UiMediaScope.Posture.Flat } -> FlatLayout()
+}
+```
+
+
+
+The `mediaQuery` and `derivedMediaQuery` functions evaluate
+the given `query` lambda within a `UiMediaScope` object,
+which is provided as `LocalUiMediaScope.current`.
+You can override it with the following steps:
+
+1. Enable the `mediaQuery` function.
+2. Define a custom object that implements the `UiMediaScope` interface.
+3. Set the custom object to the `LocalUiMediaScope` with the [`CompositionLocalProvider`](https://developer.android.com/reference/kotlin/androidx/compose/runtime/CompositionLocalProvider.composable#CompositionLocalProvider(androidx.compose.runtime.CompositionLocalContext,kotlin.Function0)) function.
+4. Call the composable to preview in the content lambda of the `CompositionLocalProvider` function.
+
+You can preview the `TabletopLayout` with the following example:
+
+
+```kotlin
+@Preview
+@Composable
+fun PreviewLayoutForTabletop() {
+ // Step 1: Enable the mediaQuery function
+ ComposeUiFlags.isMediaQueryIntegrationEnabled = true
+
+ val currentUiMediaScope = LocalUiMediaScope.current
+ // Step 2: Define a custom object implementing the UiMediaScope interface.
+ // The object overrides the windowPosture parameter.
+ // The resolution of the remaining parameters is deferred to the currentUiMediaScope object.
+ val uiMediaScope = remember(currentUiMediaScope) {
+ object : UiMediaScope by currentUiMediaScope {
+ override val windowPosture: UiMediaScope.Posture = UiMediaScope.Posture.Tabletop
+ }
+ }
+
+ // Step 3: Set the object to the LocalUiMediaScope.
+ CompositionLocalProvider(LocalUiMediaScope provides uiMediaScope) {
+ // Step 4: Call the composable to preview.
+ when {
+ mediaQuery { windowPosture == UiMediaScope.Posture.Tabletop } -> TabletopLayout()
+ mediaQuery { windowPosture == UiMediaScope.Posture.Book } -> BookLayout()
+ mediaQuery { windowPosture == UiMediaScope.Posture.Flat } -> FlatLayout()
+ }
+ }
+}
+```
+
+
\ No newline at end of file
diff --git a/.claude/skills/adaptive/references/android/develop/ui/compose/tooling/debug.md b/.claude/skills/adaptive/references/android/develop/ui/compose/tooling/debug.md
new file mode 100644
index 00000000..77bc9da9
--- /dev/null
+++ b/.claude/skills/adaptive/references/android/develop/ui/compose/tooling/debug.md
@@ -0,0 +1,114 @@
+Tools for debugging your Compose UI are available in Android Studio.
+
+## Layout Inspector
+
+Layout Inspector lets you inspect a Compose layout inside a running app in an
+emulator or physical device. You can use the Layout Inspector to check how often
+a composable is recomposed or skipped, which can help identify issues with your
+app. For example, some coding errors might force your UI to recompose
+excessively, which can cause [poor performance](https://developer.android.com/develop/ui/compose/performance).
+Some coding errors can prevent your UI from recomposing and, therefore,
+prevent your UI changes from showing up on the screen. If you're new to
+Layout inspector, check the [guidance](https://developer.android.com/studio/debug/layout-inspector) on how to
+run it.
+
+> [!NOTE]
+> **Note:** If you're not seeing Compose components in layout inspector, make sure you are not removing `META-INF/androidx.compose.*.version` files from the APK. These are required for layout inspector to work.
+
+### Get recomposition counts
+
+When debugging your Compose layouts, knowing when composables
+[recompose](https://developer.android.com/develop/ui/compose/mental-model#recomposition) is important in
+understanding whether your UI is implemented properly. For example, if it's
+recomposing too many times, your app might be doing more work than is necessary.
+On the other hand, components that don't recompose when you anticipate them to
+can lead to unexpected behaviors.
+
+The Layout Inspector shows you when discrete composables in your layout
+hierarchy have either recomposed or skipped, as you interact with your app. In
+Android Studio, your recompositions are highlighted to help you determine
+where in the UI your composables are recomposing.
+
+**Figure 1.** Recompositions are highlighted in Layout Inspector.
+
+The highlighted portion shows a gradient overlay of the composable in the image
+section of the Layout Inspector, and gradually disappears so that you can get an
+idea of where in the UI the composable with the highest recompositions can be
+found. If one composable is recomposing at a higher rate than another
+composable, then the first composable receives a stronger gradient overlay
+color. If you double-click a composable in the layout inspector, you're taken to
+the corresponding code for analysis.
+
+> [!NOTE]
+> **Note:** To view recomposition counts, make sure your app is using an API level of 29 or higher, and `Compose 1.2.0` or higher. Then, deploy your app as you normally would.
+
+ **Figure 2.**The composition and skip counter in Layout Inspector.
+
+Open the **Layout Inspector** window and connect to your app process. In the
+**Component Tree** , there are two columns that appear next to the layout
+hierarchy. The first column shows the number of compositions for each node and
+the second column displays the number of skips for each node. Selecting a
+composable node shows the dimensions and parameters of the composable, unless
+it's an inline function, in which case the parameters can't be shown. You can
+also see similar information in the **Attributes** pane when you select a
+composable from the **Component Tree** or the **Layout Display**.
+
+Resetting the count can help you understand recompositions or skips during a
+specific interaction with your app. If you want to reset the count, click
+**Reset** near the top of the **Component Tree** pane.
+
+> [!NOTE]
+> **Note:** If you don't see the new columns in the **Component Tree** pane, you can view them by selecting **Show Recomposition Counts** from the **View Options** menu  near the top of the **Component Tree** pane, as shown in the following image.
+
+
+
+**Figure 3**. Enable the composition and skip counter in Layout Inspector.
+
+### Compose semantics
+
+In Compose, [Semantics](https://developer.android.com/develop/ui/compose/semantics) describe your UI in an
+alternative manner that is understandable for
+[Accessibility](https://developer.android.com/develop/ui/compose/accessibility) services and for the
+[Testing](https://developer.android.com/develop/ui/compose/testing) framework. You can use the Layout Inspector
+to inspect semantic information in your Compose layouts.
+ **Figure 4.** Semantic information displayed using the Layout Inspector.
+
+When selecting a Compose node, use the **Attributes** pane to check whether it
+declares semantic information directly, merges semantics from its children, or
+both. To quickly identify which nodes include semantics, either declared or
+merged, use select the **View options** drop-down in the **Component Tree** pane
+and select **Highlight Semantics Layers**. This highlights only the nodes in the
+tree that include semantics, and you can use your keyboard to quickly navigate
+between them.
+
+## Compose UI Check
+
+To help you build more adaptive and accessible UIs in Jetpack Compose, Android
+Studio provides a UI Check mode in Compose Preview. This feature is similar
+to [Accessibility Scanner](https://developer.android.com/guide/topics/ui/accessibility/testing#accessibility-scanner)
+for views.
+
+When you activate Compose UI check mode on a Compose Preview, Android Studio
+automatically audits your Compose UI and suggests improvements to make your UI
+more accessible and adaptive. Android Studio checks that your UI works across
+different screen sizes. In the **Problems** panel, the tool shows the issues
+that it detects, such as text stretched on large screens or low color contrast.
+
+To access this feature, click the UI Check icon on Compose Preview:
+ **Figure 5.** Entry point to UI check mode.
+
+UI check automatically previews your UI in different configurations and
+highlights issues found in different configurations. In the **Problems** panel,
+when you click an issue, you can see the details of the issue, suggested fixes,
+and the renderings that highlight the area of the issue.
+ **Figure 6.** UI check mode in action.
+
+### Fix with AI
+
+For issues detected in UI Check mode, you can use the AI agent to propose and
+apply code fixes. Click the **Fix with AI** button on an issue in the
+**Problems** panel. The agent analyzes the problem and your code to suggest
+changes that resolve the accessibility or adaptive issue.
+ **Figure 7.** The agent fixes UI issues in UI Check mode.
\ No newline at end of file
diff --git a/.claude/skills/adaptive/references/android/guide/navigation/navigation-3/recipes/material-listdetail.md b/.claude/skills/adaptive/references/android/guide/navigation/navigation-3/recipes/material-listdetail.md
new file mode 100644
index 00000000..fbaae791
--- /dev/null
+++ b/.claude/skills/adaptive/references/android/guide/navigation/navigation-3/recipes/material-listdetail.md
@@ -0,0 +1,141 @@
+# Material List-Detail Recipe
+
+This recipe demonstrates how to create an adaptive list-detail layout using the `ListDetailSceneStrategy` from the Material 3 Adaptive library. This layout automatically adjusts to show one, two, or three panes depending on the available screen width.
+
+## How it works
+
+This example has three destinations: `ConversationList`, `ConversationDetail`, and `Profile`.
+
+### `ListDetailSceneStrategy`
+
+The key to this recipe is the `rememberListDetailSceneStrategy`, which provides the logic for the adaptive layout.
+
+- **Pane Roles**: Each destination is assigned a role using metadata:
+
+ - `ListDetailSceneStrategy.listPane()`: For the primary (list) content. This pane is always visible. A placeholder can be provided to be shown in the detail pane area when no detail content is selected.
+ - `ListDetailSceneStrategy.detailPane()`: For the secondary (detail) content.
+ - `ListDetailSceneStrategy.extraPane()`: For tertiary content.
+- **Adaptive Layout** : The `ListDetailSceneStrategy` automatically handles the layout. On smaller screens, only one pane is shown at a time. On wider screens, it will show the list and detail panes side-by-side. On very wide screens, it can show all three panes: list, detail, and extra.
+
+- **Navigation** : Navigation between the panes is handled by adding and removing destinations from the back stack as usual. The `ListDetailSceneStrategy` observes the back stack and adjusts the layout accordingly.
+
+[ Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/material/listdetail)
+
+```
+package com.example.nav3recipes.material.listdetail
+
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
+import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
+import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
+import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy
+import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.dropUnlessResumed
+import androidx.navigation3.runtime.NavKey
+import androidx.navigation3.runtime.entryProvider
+import androidx.navigation3.runtime.rememberNavBackStack
+import androidx.navigation3.ui.NavDisplay
+import com.example.nav3recipes.content.ContentBlue
+import com.example.nav3recipes.content.ContentGreen
+import com.example.nav3recipes.content.ContentRed
+import com.example.nav3recipes.content.ContentYellow
+import com.example.nav3recipes.ui.setEdgeToEdgeConfig
+import kotlinx.serialization.Serializable
+
+@Serializable
+private object ConversationList : NavKey
+
+@Serializable
+private data class ConversationDetail(val id: String) : NavKey
+
+@Serializable
+private data object Profile : NavKey
+
+class MaterialListDetailActivity : ComponentActivity() {
+
+ @OptIn(ExperimentalMaterial3AdaptiveApi::class)
+ override fun onCreate(savedInstanceState: Bundle?) {
+ setEdgeToEdgeConfig()
+ super.onCreate(savedInstanceState)
+
+ setContent {
+
+ val backStack = rememberNavBackStack(ConversationList)
+
+ // Override the defaults so that there isn't a horizontal space between the panes.
+ // See b/418201867
+ val windowAdaptiveInfo = currentWindowAdaptiveInfoV2()
+ val directive = remember(windowAdaptiveInfo) {
+ calculatePaneScaffoldDirective(windowAdaptiveInfo)
+ .copy(horizontalPartitionSpacerSize = 0.dp)
+ }
+ val listDetailStrategy = rememberListDetailSceneStrategy(directive = directive)
+
+ NavDisplay(
+ backStack = backStack,
+ onBack = { backStack.removeLastOrNull() },
+ sceneStrategies = listOf(listDetailStrategy),
+ entryProvider = entryProvider {
+ entry(
+ metadata = ListDetailSceneStrategy.listPane(
+ detailPlaceholder = {
+ ContentYellow("Choose a conversation from the list")
+ }
+ )
+ ) {
+ ContentRed("Welcome to Nav3") {
+ Button(onClick = dropUnlessResumed {
+ backStack.add(ConversationDetail("ABC"))
+ }) {
+ Text("View conversation")
+ }
+ }
+ }
+ entry(
+ metadata = ListDetailSceneStrategy.detailPane()
+ ) { conversation ->
+ ContentBlue("Conversation ${conversation.id} ") {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Button(onClick = dropUnlessResumed {
+ backStack.add(Profile)
+ }) {
+ Text("View profile")
+ }
+ }
+ }
+ }
+ entry(
+ metadata = ListDetailSceneStrategy.extraPane()
+ ) {
+ ContentGreen("Profile")
+ }
+ }
+ )
+ }
+ }
+}
+```
\ No newline at end of file
diff --git a/.claude/skills/migrate-xml-views-to-jetpack-compose/SKILL.md b/.claude/skills/migrate-xml-views-to-jetpack-compose/SKILL.md
new file mode 100644
index 00000000..a6005e31
--- /dev/null
+++ b/.claude/skills/migrate-xml-views-to-jetpack-compose/SKILL.md
@@ -0,0 +1,124 @@
+---
+name: migrate-xml-views-to-jetpack-compose
+description: Provides a structured workflow for migrating an Android XML View to Jetpack
+ Compose. This skill details the step-by-step process, from planning and dependency
+ setup, to theming and layout migration, validation and XML cleanup. Use this skill
+ when you need to migrate an XML View to Jetpack Compose in an Android project. It
+ solves the problem of converting the UI of a legacy XML View into modern, declarative
+ Compose components while maintaining interoperability.
+license: Complete terms in LICENSE.txt
+metadata:
+ author: Google LLC
+ last-updated: '2026-06-08'
+ keywords:
+ - Jetpack Compose
+ - migration
+ - XML
+ - Views
+ - interoperability
+ - incremental adoption
+ - UI development
+---
+
+This skill guides through the process of migrating an existing Android XML View
+to Jetpack Compose. It performs a stable, safe and visually consistent
+transition by following a structured, 10-step methodology. This skill migrates
+UI (XML to Jetpack Compose) only.
+
+## Objective
+
+To systematically convert a single legacy XML layout into modern, declarative
+Jetpack Compose UI while maintaining pixel-perfect visual parity and functional
+integrity.
+
+## Summary of the 10-step migration process
+
+1. **Identify the optimal XML candidate for migration**
+2. **Analyze the project and layout**
+3. **Create a plan**
+4. **Capture the XML View UI**
+5. **Set up Compose dependencies and compiler**
+6. **Set up Compose theming**
+7. **Migrate the XML layout to Compose**
+8. **Validate the migration**
+9. **Replace usages**
+10. **XML code removal**
+
+## Detailed steps
+
+### Step 1: Identify the optimal XML candidate for migration
+
+If the user has explicitly specified a target XML layout, proceed to Step 2.
+Otherwise, analyze the codebase to identify the best candidate for migration by
+following the logic in [references/identify-optimal-xml-candidate.md](references/identify-optimal-xml-candidate.md).
+
+### Step 2: Analyze the project and layout
+
+Analyze the identified XML View's structure, hierarchy, and implementation
+details.
+Use [references/analysis-of-the-project-and-layout.md](references/analysis-of-the-project-and-layout.md) to
+guide your technical audit of the layout and surrounding project context.
+
+### Step 3: Create a plan
+
+Using the outputs and analysis done in the Step 1 and 2, generate a
+step-by-step plan for the migration. If you support user interaction, present
+to the user and ask for approval before proceeding. If user interaction is not
+supported, proceed to Step 4 following the generated plan.
+
+### Step 4: Capture the XML View UI
+
+**IF** you support user interaction, ask the user to upload a screenshot of the
+XML View UI or provide an absolute path to a file. Use this image as a visual
+reference for the layout migration in Step 7.
+**ELSE IF** you are able to run an Android emulator, locate an existing
+screenshot test for the XML candidate. If none exists, create one using the
+existing project testing framework. If no framework exists,
+use **UI Automator** or **Espresso** to create a screenshot test with minimum
+required setup. Run the test and take a baseline screenshot of the XML UI.
+**ELSE** proceed to Step 5.
+
+### Step 5: Set up Compose dependencies and compiler
+
+Check `build.gradle` or `libs.versions.toml` for Compose dependencies and
+compiler setup. If missing, use
+[Setup Compose Dependencies and Compiler](references/android/develop/ui/compose/setup-compose-dependencies-and-compiler.md).
+Run a sync to ensure dependencies resolve without errors.
+
+### Step 6: Set up Compose theming
+
+If the project already has Compose theming set up, proceed to Step 7. If Compose
+theming is missing, initialize it. For Material-based projects, follow
+[Material 3 migration guidelines](references/android/develop/ui/compose/designsystems/migrate-xml-theme-to-compose.md).
+For custom design systems, apply expert judgment to migrate XML theming and
+match existing styles.
+**Constraints:** Do not migrate the entire theme. Implement only the minimum
+theming required for the specific XML candidate. Maintain original XML themes
+for interoperability. Maintain existing project code conventions, patterns,
+names and values.
+
+### Step 7: Migrate the XML View to Compose
+
+Convert the XML candidate to Jetpack Compose code, referencing
+[references/xml-layout-migration.md](references/xml-layout-migration.md) and the image from Step 4.
+You must include a **Compose Preview** for the newly created composable to
+facilitate visual verification.
+
+### Step 8: Replace usages
+
+Replace the usages of the migrated XML layout to use the new Compose component.
+
+- To add Compose in Views, use [Compose in Views](references/android/develop/ui/compose/migrate/interoperability-apis/compose-in-views.md).
+- To add Views in Compose, use [Views in Compose](references/android/develop/ui/compose/migrate/interoperability-apis/views-in-compose.md).
+
+### Step 9: Validate the migration
+
+Compare the baseline screenshot image from Step 4 with the rendered Compose
+Preview of the new composable. Ignore string content; focus on layout and
+styling. Iterate on the Compose code until visual parity is achieved. Once
+verified, write a Compose UI test for the new composable.
+
+### Step 10: XML code removal
+
+Delete the migrated XML file and its associated legacy tests. **Caution:** Only
+remove code and resources that are not referenced by other parts of the project.
diff --git a/.claude/skills/migrate-xml-views-to-jetpack-compose/references/analysis-of-the-project-and-layout.md b/.claude/skills/migrate-xml-views-to-jetpack-compose/references/analysis-of-the-project-and-layout.md
new file mode 100644
index 00000000..3e2f0c34
--- /dev/null
+++ b/.claude/skills/migrate-xml-views-to-jetpack-compose/references/analysis-of-the-project-and-layout.md
@@ -0,0 +1,42 @@
+## 1. Project health \& build validation
+
+Before performing any analysis, you must confirm the project is in a functional state.
+\* **Integrity check:** Verify the project syncs (Gradle) and builds successfully.
+\* **Error resolution:** If there are pre-existing build errors or sync failures, you must report these immediately and attempt to fix. **Do not proceed** with migration until a stable baseline is established.
+
+## 2. Compose pattern \& consistency analysis
+
+If Jetpack Compose is already present, you must align with the established implementation style.
+\* **Pattern identification:** Scan the codebase for `@Composable` functions. Identify the project's "Best Practices" regarding state hoisting, composable construction and naming conventions, and file organization.
+\* **Theming review:** Determine how `MaterialTheme` or custom theme systems are implemented.
+\* Identify if the project uses a custom design system theme.
+\* Map how attributes, styles, and other theme components are accessed in Compose.
+
+## 3. Design system \& infrastructure audit
+
+Understand the design system classification (e.g. Material 2, Material 3, or custom design system).
+\* **Resource mapping:** Locate central XML definitions:
+\* `colors.xml` (Light/Dark variants)
+\* `dimens.xml`
+\* `styles.xml` / `themes.xml`
+\* **Hybrid analysis:** Determine if the project is **XML-only** , **Compose-only** , or **Hybrid** .
+\* **Reuse constraint:** If a Compose theming layer (e.g., `AppTheme.kt`) already exists, **DO NOT** generate a new one. You must reuse the existing infrastructure and contribute to it by following its existing implementation pattern.
+
+## 4. Candidate layout decomposition
+
+Analyze the specific XML layout targeted for migration. You must extract and document the following requirements for the new composable:
+\* **Inputs:** UI State objects, primitive parameters, and click listeners.
+\* **Styling:** Specific color constants, typography styles, and shape definitions referenced in the XML.
+\* **Resources:** Identifying string resources, drawables, and dimensions.
+\* **Layout logic:** Modifiers required to replicate the XML constraints (padding, alignment, weight).
+
+## 5. Architectural \& non-UI analysis
+
+Understand the environment in which the UI resides to ensure proper integration.
+\* **State management:** Identify the usage of `ViewModel`, `Flow`, or `LiveData`.
+\* **Dependency Injection:** Check for Hilt, Koin, or manual DI to understand how dependencies are provided to the UI layer.
+\* **Testing \& architecture:** Note the architectural pattern (MVI, MVVM, or custom architecture setup.) and existing UI testing frameworks to ensure the migrated code remains testable. Unless the user explicitly requests, **DO NOT** make any changes to any non-UI code that aren't strictly required for the migration of the XML View.
+
+*** ** * ** ***
+
+> **Pro-tip:** Always prioritize the "Existing infrastructure" over "Default templates." If the project has a custom way of handling spacing or colors, composable code, or any other project layer, your generated Compose code must reflect that specific implementation.
\ No newline at end of file
diff --git a/.claude/skills/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/designsystems/migrate-xml-theme-to-compose.md b/.claude/skills/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/designsystems/migrate-xml-theme-to-compose.md
new file mode 100644
index 00000000..60b79686
--- /dev/null
+++ b/.claude/skills/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/designsystems/migrate-xml-theme-to-compose.md
@@ -0,0 +1,171 @@
+When you introduce Compose in an existing app, you need to migrate your Material
+XML themes to use `MaterialTheme` for Compose components. This means your app's
+theming will have two sources of truth: the View-based theme and the Compose
+theme. Any changes to your styling need to be made in multiple places. Once
+your app is fully migrated to Compose, remove your XML theming.
+
+You can use the [Material Theme Builder](https://m3.material.io/theme-builder)
+tool for migrating colors.
+
+When you start the migration from XML to Compose, migrate the theming to
+Material 3 Compose theming.
+
+## Glossary
+
+| Term | Definition |
+|---|---|
+| `MaterialTheme` | The composable function that provides theming (colors, typography, shapes) to Compose UI components. |
+| `Shapes` | A Compose object used to define custom component shapes for a `MaterialTheme`. |
+| `Typography` | A Compose object used to define custom text styles (font families, sizes, weights) for a `MaterialTheme`. |
+| `ColorScheme` | A Compose object used to define custom color schemes for `MaterialTheme`. |
+| XML Theme | The Android theming system defined in XML files, used by the View system. |
+
+## Limitations
+
+Before migrating, be aware of the following limitations:
+
+- This guide focuses on migrating to Material 3 only. For migrating from alternative design systems, see [Material 2](https://developer.android.com/develop/ui/compose/designsystems/material) or [Custom design systems in Compose](https://developer.android.com/develop/ui/compose/designsystems/custom).
+- The ultimate goal is a complete migration to Compose, which allows for the removal of XML theming. This guide explains how to migrate, but it doesn't explain how to finally remove XML theming.
+
+## Step 1: Evaluate the design system
+
+Identify which design system is used in the XML View project.
+Analyze the migration path and necessary steps to migrate the existing design
+system to Material 3 in Compose.
+
+## Step 2: Identify theme source files
+
+In XML you write `?attr/colorPrimary`. In Compose, you access theme values
+with `MaterialTheme.*`:
+
+Identify and locate all XML resources and files necessary for theming:
+light and dark color schemes and qualifiers, themes, shapes, dimensions,
+typography, styles and other relevant files.
+
+Resources such as strings can be reused as is and don't need to be migrated.
+
+## Step 3: Migrate colors
+
+**Key principle:** XML uses named hex colors.
+Material 3 uses *semantic roles* (e.g., `primary`, `onPrimary`, `surface`).
+Stop naming colors by their hex; name them by their role.
+
+Examples:
+
+| XML color name | Material 3 role |
+|---|---|
+| `colorPrimary` | `primary` |
+| `colorPrimaryDark` / `colorPrimaryVariant` | `primaryContainer` or `secondary` |
+| `colorAccent` | `secondary` or `tertiary` |
+| `colorOnPrimary` | `onPrimary` |
+| `android:colorBackground` | `background` |
+| `colorSurface` | `surface` |
+| `colorOnSurface` | `onSurface` |
+| `colorError` | `error` |
+| `colorOnError` | `onError` |
+| `colorOutline` | `outline` |
+| `colorSurfaceVariant` | `surfaceVariant` |
+| `colorOnSurfaceVariant` | `onSurfaceVariant` |
+
+*** ** * ** ***
+
+Migrate the dark and light color schemes from XML to their equivalents in
+Material 3 Compose.
+
+> [!NOTE]
+> **Note:** Material 3 naming differs from Material 2 color naming.
+
+## Step 4: Migrate custom shapes and typography
+
+- If your app uses custom shapes:
+
+ 1. In your Compose code, define a `Shapes` object to replicate your XML shape definitions.
+ 2. Provide this `Shapes` object to your `MaterialTheme`.
+
+ For more details, see [shapes](https://developer.android.com/develop/ui/compose/designsystems/material3#shapes).
+- If your app uses custom typography:
+
+ 1. In your Compose code, define a `Typography` object in your Compose code to replicate your XML text styles and font definitions.
+ 2. Provide this `Typography` object to your `MaterialTheme`.
+
+ For more details, see [typography](https://developer.android.com/develop/ui/compose/designsystems/material3#typography).
+
+| Compose role | XML name |
+|---|---|
+| `displayLarge` | `TextAppearance.Material3.DisplayLarge` |
+| `displayMedium` | `TextAppearance.Material3.DisplayMedium` |
+| `displaySmall` | `TextAppearance.Material3.DisplaySmall` |
+| `headlineLarge` | `TextAppearance.Material3.HeadlineLarge` |
+| `headlineMedium` | `TextAppearance.Material3.HeadlineMedium` |
+| `headlineSmall` | `TextAppearance.Material3.HeadlineSmall` |
+| `titleLarge` | `TextAppearance.Material3.TitleLarge` |
+| `titleMedium` | `TextAppearance.Material3.TitleMedium` |
+| `titleSmall` | `TextAppearance.Material3.TitleSmall` |
+| `bodyLarge` | `TextAppearance.Material3.BodyLarge` |
+| `bodyMedium` | `TextAppearance.Material3.BodyMedium` |
+| `bodySmall` | `TextAppearance.Material3.BodySmall` |
+| `labelLarge` | `TextAppearance.Material3.LabelLarge` |
+| `labelMedium` | `TextAppearance.Material3.LabelMedium` |
+| `labelSmall` | `TextAppearance.Material3.LabelSmall` |
+
+## Step 5: Migrate styles (styles.xml)
+
+XML styles (styles.xml) system defines styles and appearance of:
+
+1. Widgets, components, themes for windows and dialogs
+2. Typography
+3. Themes and overlays
+4. Shapes
+
+XML Views and components combine multiple attributes to create a style.
+They set their styles from styles.xml in two different ways:
+
+1. Setting "style="@style/..." directly and explicitly in the XML View
+2. Setting the style indirectly and implicitly for a component as part of a larger Theme (theme.xml)
+
+Styles have no **direct** equivalent in Compose - instead styles are passed as:
+parameters or modifiers to composables, using the
+[new, experimental Styles API](https://developer.android.com/develop/ui/compose/styles) defined in the AppTheme, or by creating
+layered, reusable composable variations with the defined style.
+
+Provide separate @Composable functions named according to the style and the
+base component, to signify the difference in styling and use cases for those
+components.
+
+- **Pattern:** If an XML element uses a custom style (e.g., `style="@style/MyPrimaryButton"`), don't try to replicate the style inline. Instead, suggest creating a specific composable.
+- **Example:**
+ - *XML:* ``
+ - *Compose:* `MyPrimaryButton(onClick = { ... })`
+- **Common Attribute Groups:** If a style sets common modifiers (like padding + height), extract them into a readable extension property or a shared Modifier variable.
+
+### Common examples
+
+| XML | Compose |
+|---|---|
+| `Theme.Material3.*` | `MaterialTheme(colorScheme, typography, shapes) { }` |
+| `TextAppearance.Material3.BodyMedium` | `TextStyle(...)` defined in `Typography(bodyMedium = ...)` |
+| `ShapeAppearance.*.SmallComponent` | `Shapes(small = RoundedCornerShape(X.dp))` |
+| `Widget.Material3.Button` | `Button(colors = ButtonDefaults.buttonColors(...))` |
+| `Widget.Material3.CardView` | `Card(shape=..., elevation=..., colors=...)` |
+| `Widget.*.TextInputLayout.OutlinedBox` | `OutlinedTextField(colors = OutlinedTextFieldDefaults.colors(...))` |
+| `Widget.*.Chip.Filter` | `FilterChip(colors = FilterChipDefaults.filterChipColors(...))` |
+| `Widget.*.Toolbar.Primary` | `TopAppBar(colors = TopAppBarDefaults.topAppBarColors(...))` |
+| `Widget.*.FloatingActionButton` | `FloatingActionButton(containerColor = ...)` |
+| `backgroundTint` | `containerColor` in `ComponentDefaults.ComponentColors()` |
+| `android:textColor` | `contentColor` in `ComponentDefaults.ComponentColors()` |
+| `cornerRadius` | `shape = RoundedCornerShape(X.dp)` |
+| `android:elevation` | `elevation = ComponentDefaults.elevation(defaultElevation = X.dp)` |
+| `android:padding` | `contentPadding = PaddingValues(...)` or `Modifier.padding()` |
+| `android:minHeight` | `Modifier.heightIn(min = X.dp)` |
+| `strokeColor` + `strokeWidth` | `border = BorderStroke(width, color)` |
+| `android:textSize` | `fontSize = X.sp` in `TextStyle` |
+
+## Step 6: Validate the theme migration
+
+Always use the existing theme values from the original XML theme as the source
+of truth for the new Material Theme in Compose.
+Never invent new theme values during migration, to maintain brand consistency
+and avoid visual regressions.
+
+Verify all new Compose theme values match the existing XML values.
+Don't hardcode any migrated values.
\ No newline at end of file
diff --git a/.claude/skills/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/migrate/interoperability-apis/compose-in-views.md b/.claude/skills/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/migrate/interoperability-apis/compose-in-views.md
new file mode 100644
index 00000000..83c71881
--- /dev/null
+++ b/.claude/skills/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/migrate/interoperability-apis/compose-in-views.md
@@ -0,0 +1,299 @@
+> [!NOTE]
+> **Note:** Interoperability is supported for hybrid apps that use Compose and Views. For apps that are only in Compose, use the recommended Compose-only architecture with a single Activity and latest navigation libraries, like Navigation 3.
+
+You can add Compose-based UI into an existing app that uses a View-based design.
+
+To create a new, entirely Compose-based screen, have your
+activity call the `setContent()` method, and pass whatever composable functions
+you like.
+
+
+```kotlin
+class ExampleActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContent { // In here, we can call composables!
+ MaterialTheme {
+ Greeting(name = "compose")
+ }
+ }
+ }
+}
+
+@Composable
+fun Greeting(name: String) {
+ Text(text = "Hello $name!")
+}
+```
+
+
+
+This code looks just like what you'd find in a Compose-only app.
+
+> [!CAUTION]
+> **Caution:** To use the `ComponentActivity.setContent`
+> method, add the `androidx.activity:activity-compose:$latestVersion`
+> dependency to your `build.gradle` file.
+>
+> See the [Activity releases page](https://developer.android.com/jetpack/androidx/releases/activity)
+> to find out the latest version.
+
+## `ViewCompositionStrategy` for `ComposeView`
+
+[`ViewCompositionStrategy`](https://developer.android.com/reference/kotlin/androidx/compose/ui/platform/ViewCompositionStrategy)
+defines when the Composition should be disposed. The default,
+[`ViewCompositionStrategy.Default`](https://developer.android.com/reference/kotlin/androidx/compose/ui/platform/ViewCompositionStrategy#Default()),
+disposes the Composition when the underlying
+[`ComposeView`](https://developer.android.com/reference/kotlin/androidx/compose/ui/platform/ComposeView)
+detaches from the window, unless it is part of a pooling container such as a
+`RecyclerView`. In a single-Activity Compose-only app, this default behavior is
+what you would want, however, if you are incrementally adding Compose in your
+codebase, this behavior may cause state loss in some scenarios.
+
+To change the `ViewCompositionStrategy`, call the [`setViewCompositionStrategy()`](https://developer.android.com/reference/kotlin/androidx/compose/ui/platform/AbstractComposeView#setViewCompositionStrategy(androidx.compose.ui.platform.ViewCompositionStrategy))
+method and provide a different strategy.
+
+The table below summarizes the different scenarios you can use
+`ViewCompositionStrategy` in:
+
+| `ViewCompositionStrategy` | Description and Interop Scenario |
+|---|---|
+| [`DisposeOnDetachedFromWindow`](https://developer.android.com/reference/kotlin/androidx/compose/ui/platform/ViewCompositionStrategy.DisposeOnDetachedFromWindow) | The Composition will be disposed when the underlying `ComposeView` is detached from the window. Has since been superseded by `DisposeOnDetachedFromWindowOrReleasedFromPool`. Interop scenario: \* `ComposeView` whether it's the sole element in the View hierarchy, or in the context of a mixed View/Compose screen (not in Fragment). |
+| [`DisposeOnDetachedFromWindowOrReleasedFromPool`](https://developer.android.com/reference/kotlin/androidx/compose/ui/platform/ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool) (**Default**) | Similar to `DisposeOnDetachedFromWindow`, when the Composition is not in a pooling container, such as a `RecyclerView`. If it is in a pooling container, it will dispose when either the pooling container itself detaches from the window, or when the item is being discarded (i.e. when the pool is full). Interop scenario: \* `ComposeView` whether it's the sole element in the View hierarchy, or in the context of a mixed View/Compose screen (not in Fragment). \* `ComposeView` as an item in a pooling container such as `RecyclerView`. |
+| [`DisposeOnLifecycleDestroyed`](https://developer.android.com/reference/kotlin/androidx/compose/ui/platform/ViewCompositionStrategy.DisposeOnLifecycleDestroyed) | The Composition will be disposed when the provided [`Lifecycle`](https://developer.android.com/reference/androidx/lifecycle/Lifecycle) is destroyed. Interop scenario \* `ComposeView` in a Fragment's View. |
+| [`DisposeOnViewTreeLifecycleDestroyed`](https://developer.android.com/reference/kotlin/androidx/compose/ui/platform/ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) | The Composition will be disposed when the `Lifecycle` owned by the `LifecycleOwner` returned by `ViewTreeLifecycleOwner.get` of the next window the View is attached to is destroyed. Interop scenario: \* `ComposeView` in a Fragment's View. \* `ComposeView` in a View wherein the Lifecycle is not known yet. |
+
+## `ComposeView` in Fragments (Transitionary Step)
+
+> [!NOTE]
+> **Note:** For hybrid apps with Views and Compose, that use Fragments, use `ComposeView` to wrap composables content and add to a Fragment during migration. For Compose-only apps, do not use Fragments and instead use the recommended Compose-only architecture with a single Activity and latest navigation libraries, like Navigation 3.
+
+If you want to incorporate Compose UI content in a fragment or an existing View
+layout, use [`ComposeView`](https://developer.android.com/reference/kotlin/androidx/compose/ui/platform/ComposeView)
+and call its
+[`setContent()`](https://developer.android.com/reference/kotlin/androidx/compose/ui/platform/ComposeView#setContent(kotlin.Function0))
+method. `ComposeView` is an Android [`View`](https://developer.android.com/reference/android/view/View).
+
+You can put the `ComposeView` in your XML layout just like any other `View`:
+
+```xml
+
+
+
+
+
+
+```
+
+In the Kotlin source code, inflate the layout from the [layout
+resource](https://developer.android.com/guide/topics/resources/layout-resource) defined in XML. Then get the
+`ComposeView` using the XML ID, set a Composition strategy that works best for
+the host `View`, and call `setContent()` to use Compose.
+
+
+```kotlin
+class ExampleFragmentXml : Fragment() {
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ val view = inflater.inflate(R.layout.fragment_example, container, false)
+ val composeView = view.findViewById(R.id.compose_view)
+ composeView.apply {
+ // Dispose of the Composition when the view's LifecycleOwner
+ // is destroyed
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+ setContent {
+ // In Compose world
+ MaterialTheme {
+ Text("Hello Compose!")
+ }
+ }
+ }
+ return view
+ }
+}
+```
+
+
+
+Alternatively, you can also use view binding to obtain references to the
+`ComposeView` by referencing the generated binding class for your XML layout file:
+
+
+```kotlin
+class ExampleFragment : Fragment() {
+
+ private var _binding: FragmentExampleBinding? = null
+
+ // This property is only valid between onCreateView and onDestroyView.
+ private val binding get() = _binding!!
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentExampleBinding.inflate(inflater, container, false)
+ val view = binding.root
+ binding.composeView.apply {
+ // Dispose of the Composition when the view's LifecycleOwner
+ // is destroyed
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+ setContent {
+ // In Compose world
+ MaterialTheme {
+ Text("Hello Compose!")
+ }
+ }
+ }
+ return view
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
+```
+
+
+
+
+
+**Figure 1.** This shows the output of the code that adds Compose elements in a
+View UI hierarchy. The "Hello Android!" text is displayed by a
+`TextView` widget. The "Hello Compose!" text is displayed by a
+Compose text element.
+
+You can also include a `ComposeView` directly in a fragment if your full screen
+is built with Compose, which lets you avoid using an XML layout file entirely.
+
+
+```kotlin
+class ExampleFragmentNoXml : Fragment() {
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ return ComposeView(requireContext()).apply {
+ // Dispose of the Composition when the view's LifecycleOwner
+ // is destroyed
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+ setContent {
+ MaterialTheme {
+ // In Compose world
+ Text("Hello Compose!")
+ }
+ }
+ }
+ }
+}
+```
+
+
+
+## Multiple `ComposeView` instances in the same layout
+
+If there are multiple `ComposeView` elements in the same layout, each one must
+have a unique ID for `savedInstanceState` to work.
+
+
+```kotlin
+class ExampleFragmentMultipleComposeView : Fragment() {
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View = LinearLayout(requireContext()).apply {
+ addView(
+ ComposeView(requireContext()).apply {
+ setViewCompositionStrategy(
+ ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
+ )
+ id = R.id.compose_view_x
+ // ...
+ }
+ )
+ addView(TextView(requireContext()))
+ addView(
+ ComposeView(requireContext()).apply {
+ setViewCompositionStrategy(
+ ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
+ )
+ id = R.id.compose_view_y
+ // ...
+ }
+ )
+ }
+}
+```
+
+
+
+The `ComposeView` IDs are defined in the `res/values/ids.xml` file:
+
+```xml
+
+
+
+
+```
+
+## Preview composables in Layout Editor
+
+You can also preview composables within the Layout Editor for your XML layout
+containing a `ComposeView`. Doing so lets you see how your composables look
+within a mixed Views and Compose layout.
+
+Say you want to display the following composable in the Layout Editor. Note
+that composables annotated with `@Preview` are good candidates to preview in the
+Layout Editor.
+
+
+```kotlin
+@Preview
+@Composable
+fun GreetingPreview() {
+ Greeting(name = "Android")
+}
+```
+
+
+
+To display this composable, use the `tools:composableName` tools attribute and
+set its value to the fully qualified name of the composable to preview in the
+layout.
+
+```xml
+
+
+
+
+
+```
+
+
\ No newline at end of file
diff --git a/.claude/skills/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/migrate/interoperability-apis/views-in-compose.md b/.claude/skills/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/migrate/interoperability-apis/views-in-compose.md
new file mode 100644
index 00000000..14b9121d
--- /dev/null
+++ b/.claude/skills/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/migrate/interoperability-apis/views-in-compose.md
@@ -0,0 +1,286 @@
+You can include an Android View hierarchy in a Compose UI. This approach is
+particularly useful if you want to use UI elements that are not yet available in
+Compose, like
+[`AdView`](https://developers.google.com/android/reference/com/google/android/gms/ads/AdView).
+This approach also lets you reuse custom views you may have designed.
+
+> [!NOTE]
+> **Note:** Use `AndroidView` as a wrapper only for missing SDK components without Compose support. Rewrite your custom Views in Compose wherever possible, starting the migration with the simplest custom Views and scaling to more complex ones.
+
+
+
+To include a view element or hierarchy, use the [`AndroidView`](https://developer.android.com/reference/kotlin/androidx/compose/ui/viewinterop/AndroidView.composable#AndroidView(kotlin.Function1,androidx.compose.ui.Modifier,kotlin.Function1))
+composable. `AndroidView` is passed a lambda that returns a
+[`View`](https://developer.android.com/reference/android/view/View). `AndroidView` also provides an `update`
+callback that is called when the view is inflated. The `AndroidView` recomposes
+whenever a `State` read within the callback changes. `AndroidView`, like many
+other built-in composables, takes a `Modifier` parameter that can be used, for
+example, to set its position in the parent composable.
+
+
+```kotlin
+@Composable
+fun CustomView() {
+ var selectedItem by remember { mutableIntStateOf(0) }
+
+ // Adds view to Compose
+ AndroidView(
+ modifier = Modifier.fillMaxSize(), // Occupy the max size in the Compose UI tree
+ factory = { context ->
+ // Creates view
+ MyView(context).apply {
+ // Sets up listeners for View -> Compose communication
+ setOnClickListener {
+ selectedItem = 1
+ }
+ }
+ },
+ update = { view ->
+ // View's been inflated or state read in this block has been updated
+ // Add logic here if necessary
+
+ // As selectedItem is read here, AndroidView will recompose
+ // whenever the state changes
+ // Example of Compose -> View communication
+ view.selectedItem = selectedItem
+ }
+ )
+}
+
+@Composable
+fun ContentExample() {
+ Column(Modifier.fillMaxSize()) {
+ Text("Look at this CustomView!")
+ CustomView()
+ }
+}
+```
+
+
+
+> [!NOTE]
+> **Note:** Prefer to construct a View in the `AndroidView` `factory` lambda instead of using `remember` to hold a View reference outside of `AndroidView`.
+
+## `AndroidView` with view binding
+
+To embed an XML layout, use the
+[`AndroidViewBinding`](https://developer.android.com/reference/kotlin/androidx/compose/ui/viewinterop/package-summary#AndroidViewBinding(kotlin.Function3,%20androidx.compose.ui.Modifier,%20kotlin.Function1))
+API, which is provided by the `androidx.compose.ui:ui-viewbinding` library. To
+do this, your project must enable [view binding](https://developer.android.com/topic/libraries/view-binding#setup).
+
+> [!NOTE]
+> **Note:** For Compose-only apps, don't use `AndroidViewBinding` to inflate full screen-level XML layouts, and instead use it only for smaller, legacy XML layouts during the incremental migration process.
+
+
+```kotlin
+@Composable
+fun AndroidViewBindingExample() {
+ AndroidViewBinding(ExampleLayoutBinding::inflate) {
+ exampleView.setBackgroundColor(Color.GRAY)
+ }
+}
+```
+
+
+
+## `AndroidView` in Lazy lists
+
+If you are using an `AndroidView` in a Lazy list (`LazyColumn`, `LazyRow`,
+`Pager`, etc.), consider using the [`AndroidView`](https://developer.android.com/reference/kotlin/androidx/compose/ui/viewinterop/package-summary#AndroidView(kotlin.Function1,kotlin.Function1,androidx.compose.ui.Modifier,kotlin.Function1,kotlin.Function1))
+overload introduced in version 1.4.0-rc01. This overload allows Compose to reuse
+the underlying `View` instance when the containing composition is reused as is
+the case for Lazy lists.
+
+This overload of `AndroidView` adds 2 additional parameters:
+
+- `onReset` - A callback invoked to signal that the `View` is about to be reused. This must be non-null to enable View reuse.
+- `onRelease` (optional) - A callback invoked to signal that the `View` has exited the composition and will not be reused again.
+
+
+```kotlin
+@Composable
+fun AndroidViewInLazyList() {
+ LazyColumn {
+ items(100) { index ->
+ AndroidView(
+ modifier = Modifier.fillMaxSize(), // Occupy the max size in the Compose UI tree
+ factory = { context ->
+ MyView(context)
+ },
+ update = { view ->
+ view.selectedItem = index
+ },
+ onReset = { view ->
+ view.clear()
+ }
+ )
+ }
+ }
+}
+```
+
+
+
+## Fragments in Compose (Transitionary Step)
+
+Use the `AndroidFragment` composable to add a `Fragment` in Compose.
+`AndroidFragment` has fragment-specific handling such as removing the
+fragment when the composable leaves the composition.
+
+> [!NOTE]
+> **Note:** Wrap existing Fragments in Compose only during incremental migration. For Compose-only apps, do not use Fragments and instead use the recommended Compose-only architecture with a single Activity and latest navigation libraries, like Navigation 3.
+
+To include a fragment, use the [`AndroidFragment`](https://developer.android.com/reference/kotlin/androidx/fragment/compose/package-summary#AndroidFragment)
+composable. You pass a `Fragment` class to `AndroidFragment`, which then adds
+an instance of that class directly into the composition. `AndroidFragment` also
+provides a `fragmentState` object to create the `AndroidFragment` with a given
+state, `arguments` to pass into the new fragment, and an `onUpdate` callback
+that provides the fragment from the composition. Like many
+other built-in composables, `AndroidFragment` accepts a `Modifier` parameter
+that you can use, for
+example, to set its position in the parent composable.
+
+Call `AndroidFragment` in Compose as follows:
+
+
+```kotlin
+@Composable
+fun FragmentInComposeExample() {
+ AndroidFragment()
+}
+```
+
+
+
+## Calling the Android framework from Compose
+
+Compose operates within the Android framework classes. For example, it's hosted
+on Android View classes, like `Activity` or `Fragment`, and might use Android
+framework classes like the `Context`, system resources,
+`Service`, or `BroadcastReceiver`.
+
+To learn more about system resources, see [Resources in Compose](https://developer.android.com/develop/ui/compose/resources).
+
+### Composition Locals
+
+[`CompositionLocal`](https://developer.android.com/reference/kotlin/androidx/compose/runtime/CompositionLocal)
+classes allow passing data implicitly through composable functions. They're
+usually provided with a value in a certain node of the UI tree. That value can
+be used by its composable descendants without declaring the `CompositionLocal`
+as a parameter in the composable function.
+
+`CompositionLocal` is used to propagate values for Android framework types in
+Compose such as `Context`, `Configuration` or the `View` in which the Compose
+code is hosted with the corresponding
+[`LocalContext`](https://developer.android.com/reference/kotlin/androidx/compose/ui/platform/package-summary#LocalContext()),
+[`LocalConfiguration`](https://developer.android.com/reference/kotlin/androidx/compose/ui/platform/package-summary#LocalConfiguration()),
+or
+[`LocalView`](https://developer.android.com/reference/kotlin/androidx/compose/ui/platform/package-summary#LocalView()).
+Note that `CompositionLocal` classes are prefixed with `Local` for better
+discoverability with auto-complete in the IDE.
+
+Access the current value of a `CompositionLocal` by using its `current`
+property. For example, the code below shows a toast message by providing
+`LocalContext.current` into the `Toast.makeToast` method.
+
+
+```kotlin
+@Composable
+fun ToastGreetingButton(greeting: String) {
+ val context = LocalContext.current
+ Button(onClick = {
+ Toast.makeText(context, greeting, Toast.LENGTH_SHORT).show()
+ }) {
+ Text("Greet")
+ }
+}
+```
+
+
+
+### Broadcast receivers
+
+To showcase `CompositionLocal` and [side
+effects](https://developer.android.com/develop/ui/compose/side-effects), if a
+[`BroadcastReceiver`](https://developer.android.com/guide/components/broadcasts) needs to be registered from
+a composable function, use of `LocalContext` to use the current context, and
+`rememberUpdatedState` and `DisposableEffect` side effects.
+
+
+```kotlin
+@Composable
+fun SystemBroadcastReceiver(
+ systemAction: String,
+ onSystemEvent: (intent: Intent?) -> Unit
+) {
+ // Grab the current context in this part of the UI tree
+ val context = LocalContext.current
+
+ // Safely use the latest onSystemEvent lambda passed to the function
+ val currentOnSystemEvent by rememberUpdatedState(onSystemEvent)
+
+ // If either context or systemAction changes, unregister and register again
+ DisposableEffect(context, systemAction) {
+ val intentFilter = IntentFilter(systemAction)
+ val broadcast = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ currentOnSystemEvent(intent)
+ }
+ }
+
+ context.registerReceiver(broadcast, intentFilter)
+
+ // When the effect leaves the Composition, remove the callback
+ onDispose {
+ context.unregisterReceiver(broadcast)
+ }
+ }
+}
+
+@Composable
+fun HomeScreen() {
+
+ SystemBroadcastReceiver(Intent.ACTION_BATTERY_CHANGED) { batteryStatus ->
+ val isCharging = /* Get from batteryStatus ... */ true
+ /* Do something if the device is charging */
+ }
+
+ /* Rest of the HomeScreen */
+}
+```
+
+
+
+## Other interactions
+
+If there isn't a utility defined for the interaction you need, the best practice
+is to follow the general Compose guideline,
+*data flows down, events flow up* (discussed at more length in [Thinking
+in Compose](https://developer.android.com/develop/ui/compose/mental-model)). For example, this composable
+launches a different activity:
+
+
+```kotlin
+class OtherInteractionsActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ // get data from savedInstanceState
+ setContent {
+ MaterialTheme {
+ ExampleComposable(data, onButtonClick = {
+ startActivity(Intent(this, MyActivity::class.java))
+ })
+ }
+ }
+ }
+}
+
+@Composable
+fun ExampleComposable(data: DataExample, onButtonClick: () -> Unit) {
+ Button(onClick = onButtonClick) {
+ Text(data.title)
+ }
+}
+```
+
+
\ No newline at end of file
diff --git a/.claude/skills/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/setup-compose-dependencies-and-compiler.md b/.claude/skills/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/setup-compose-dependencies-and-compiler.md
new file mode 100644
index 00000000..5abc55e9
--- /dev/null
+++ b/.claude/skills/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/setup-compose-dependencies-and-compiler.md
@@ -0,0 +1,183 @@
+## Set up the Compose Compiler Gradle plugin
+
+For Gradle, use the Compose Compiler Gradle plugin to set
+up and configure Compose.
+
+> [!NOTE]
+> **Note:** The Compose Compiler Gradle Plugin is only available from Kotlin 2.0+. For migration instructions, see ["Jetpack Compose compiler moving to the Kotlin
+> repository"](https://android-developers.googleblog.com/2024/04/jetpack-compose-compiler-moving-to-kotlin-repository.html).
+
+### Set up with Gradle version catalogs
+
+Set up the Compose Compiler Gradle plugin:
+
+1. In the `libs.versions.toml` file, remove any reference to the Compose Compiler.
+2. In the `versions` and `plugins` sections, add the new dependency:
+
+ [versions]
+ kotlin = "2.3.21"
+
+ [plugins]
+ org-jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+
+ // Add this line
+ compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+
+1. In the project's root `build.gradle.kts` file, add the following to the `plugins` section.
+
+ plugins {
+ // Existing plugins
+ alias(libs.plugins.compose.compiler) apply false
+ }
+
+1. In each module that uses Compose, apply the plugin:
+
+ plugins {
+ // Existing plugins
+ alias(libs.plugins.compose.compiler)
+ }
+
+The project should now build and compile if it was using the default set up. If
+it had configured custom options on the Compose compiler, follow the next
+section.
+
+### Set up the Compose Compiler without Gradle version catalogs
+
+Add the plugin to `build.gradle.kts` files associated with modules where Compose
+is used:
+
+ plugins {
+ id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" // this version matches your Kotlin version
+ }
+
+Add the classpath to your top-level project `build.gradle.kts` file:
+
+ buildscript {
+ dependencies {
+ classpath("org.jetbrains.kotlin.plugin.compose:org.jetbrains.kotlin.plugin.compose.gradle.plugin:2.3.21")
+ }
+ }
+
+### Configuration options with the Compose Compiler Gradle Plugin
+
+To configure the Compose compiler using the Gradle plugin, add the
+`composeCompiler` block to the module's `build.gradle.kts` file at the top
+level:
+
+ android { ... }
+
+ composeCompiler {
+ reportsDestination = layout.buildDirectory.dir("compose_compiler")
+ stabilityConfigurationFile = rootProject.layout.projectDirectory.file("stability_config.conf")
+ }
+
+For the full list of available options, see the [documentation](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compiler.html#compose-compiler-options-dsl).
+
+## Set up Compose dependencies
+
+Always use the latest Compose BOM version: `2026.05.00`.
+
+Set the `compose` flag to `true` inside the Android [`BuildFeatures`](https://developer.android.com/reference/tools/gradle-api/7.0/com/android/build/api/dsl/BuildFeatures)
+to enable [Compose functionality](https://developer.android.com/develop/ui/compose/tooling) in Android Studio.
+
+Add the following definition to your app's `build.gradle` file:
+
+### Groovy
+
+ android {
+ buildFeatures {
+ compose true
+ }
+ }
+
+### Kotlin
+
+ android {
+ buildFeatures {
+ compose = true
+ }
+ }
+
+Add the Compose BOM and the subset of Compose library dependencies:
+
+### Groovy
+
+ dependencies {
+
+ def composeBom = platform('androidx.compose:compose-bom:2026.05.00')
+ implementation composeBom
+ androidTestImplementation composeBom
+
+ // Choose one of the following:
+ // Material Design 3
+ implementation 'androidx.compose.material3:material3'
+ // or skip Material Design and build directly on top of foundational components
+ implementation 'androidx.compose.foundation:foundation'
+ // or only import the main APIs for the underlying toolkit systems,
+ // such as input and measurement/layout
+ implementation 'androidx.compose.ui:ui'
+
+ // Android Studio Preview support
+ implementation 'androidx.compose.ui:ui-tooling-preview'
+ debugImplementation 'androidx.compose.ui:ui-tooling'
+
+ // UI Tests
+ androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
+ debugImplementation 'androidx.compose.ui:ui-test-manifest'
+
+ // Optional - Add window size utils
+ implementation 'androidx.compose.material3.adaptive:adaptive'
+
+ // Optional - Integration with activities
+ implementation 'androidx.activity:activity-compose:1.13.0'
+ // Optional - Integration with ViewModels
+ implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0'
+ // Optional - Integration with LiveData
+ implementation 'androidx.compose.runtime:runtime-livedata'
+ // Optional - Integration with RxJava
+ implementation 'androidx.compose.runtime:runtime-rxjava2'
+
+ }
+
+### Kotlin
+
+ dependencies {
+
+ val composeBom = platform("androidx.compose:compose-bom:2026.05.00")
+ implementation(composeBom)
+ androidTestImplementation(composeBom)
+
+ // Choose one of the following:
+ // Material Design 3
+ implementation("androidx.compose.material3:material3")
+ // or skip Material Design and build directly on top of foundational components
+ implementation("androidx.compose.foundation:foundation")
+ // or only import the main APIs for the underlying toolkit systems,
+ // such as input and measurement/layout
+ implementation("androidx.compose.ui:ui")
+
+ // Android Studio Preview support
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ debugImplementation("androidx.compose.ui:ui-tooling")
+
+ // UI Tests
+ androidTestImplementation("androidx.compose.ui:ui-test-junit4")
+ debugImplementation("androidx.compose.ui:ui-test-manifest")
+
+ // Optional - Add window size utils
+ implementation("androidx.compose.material3.adaptive:adaptive")
+
+ // Optional - Integration with activities
+ implementation("androidx.activity:activity-compose:1.13.0")
+ // Optional - Integration with ViewModels
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
+ // Optional - Integration with LiveData
+ implementation("androidx.compose.runtime:runtime-livedata")
+ // Optional - Integration with RxJava
+ implementation("androidx.compose.runtime:runtime-rxjava2")
+
+ }
+
+> [!NOTE]
+> **Note:** Jetpack Compose is shipped using a Bill of Materials (BOM), to keep the versions of all library groups in sync. Read more about it in the [Bill of
+> Materials page](https://developer.android.com/develop/ui/compose/bom/bom).
\ No newline at end of file
diff --git a/.claude/skills/migrate-xml-views-to-jetpack-compose/references/identify-optimal-xml-candidate.md b/.claude/skills/migrate-xml-views-to-jetpack-compose/references/identify-optimal-xml-candidate.md
new file mode 100644
index 00000000..650ac92b
--- /dev/null
+++ b/.claude/skills/migrate-xml-views-to-jetpack-compose/references/identify-optimal-xml-candidate.md
@@ -0,0 +1,31 @@
+### 1. Analysis scope
+
+**Action:** Use find files and examine all XML layout files within the project (typically located in `res`directories). For each file, parse the view hierarchy and metadata.
+
+### 2. Selection criteria
+
+Prioritize layouts that meet the following criteria:
+
+- **Hierarchy depth:** Target **leaf nodes** or components at the bottom of the UI tree.
+- **Complexity:** Select layouts with the **smallest number of nested children** and minimal logic.
+- **State management:** Prioritize **stateless** components or those with the fewest UI state variables.
+- **Dependency footprint:** Identify layouts with **zero to minimal external UI dependencies**.
+- **Isolation:** Focus on **self-contained** components that do not rely heavily on parent context or complex data binding.
+
+### 3. Risk assessment
+
+Evaluate the migration risk based on:
+\* **Reusability:** Find layouts with **minimum reuse** across the project to limit regression impact.
+\* **Accessibility:** Ensure the layout has an **easily accessible entry point** (e.g., used in a simple Activity, Fragment, or as a standalone include).
+
+*** ** * ** ***
+
+## Output requirements
+
+Provide a ranked list of the top 3-5 candidates. For each candidate, include:
+1. **File path:** (e.g., `res/layout/item_user_profile.xml`)
+2. **Rationale:** Why this is a good candidate based on the provided criteria.
+3. **Complexity score:** A rating from 1-5 (1 being simplest).
+4. **Dependency count:** List of custom/external views found within.
+
+**Action:** If you support user interaction, ask the user to choose which XML to proceed with. Else proceed with the best option, based on the previous criteria.
\ No newline at end of file
diff --git a/.claude/skills/migrate-xml-views-to-jetpack-compose/references/xml-layout-migration.md b/.claude/skills/migrate-xml-views-to-jetpack-compose/references/xml-layout-migration.md
new file mode 100644
index 00000000..5aeb6b39
--- /dev/null
+++ b/.claude/skills/migrate-xml-views-to-jetpack-compose/references/xml-layout-migration.md
@@ -0,0 +1,86 @@
+## 1. Structural analysis \& mapping
+
+**Identify the precise mapping** between XML elements and Compose equivalents.
+You must determine:
+
+- The exact `@Composable` functions (e.g., `ConstraintLayout`, `Column`, `LazyColumn`) that replace the XML tag hierarchy.
+- The specific parameters and `Modifier` extensions required to replicate XML attributes (e.g., `layout_width`, `padding`, `elevation`).
+- The appropriate state management strategy for interactive elements.
+
+## 2. Migration execution
+
+**Convert the XML layout code to Jetpack Compose**, ensuring the visual
+hierarchy and layout logic are preserved while leveraging Compose's declarative
+nature.
+
+## 3. Theming \& design system integrity
+
+**Do not use hard-coded values.** Follow these rules for styling:
+
+- **Token Alignment:** Cross-reference XML dimensions, colors, and style attributes with the existing Compose `Theme` (e.g., `MaterialTheme.colorScheme` or custom design system tokens).
+- **Reuse over Creation:** If matching values exist in the current Compose theme, reuse them. If a value is missing but required for the design, define it within the theme structure rather than hard-coding it in the Composable.
+- **Project Consistency:** You **MUST** strictly adhere to existing code conventions, naming standards, and implementation patterns found in the project. **Prioritize** project-specific reusable components over generic Material defaults.
+
+## 4. Component layering \& reusability
+
+Evaluate if the XML layout serves as a foundation-level design system component
+(reused across the app with a distinct role). If it is:
+
+- **Create a reusable composable:** Do not just inline the code. Define a new standalone `@Composable`.
+- **Parameterization:** Expose specific parameters for variable data (text, colors, styles) and use `Modifier` for layout-specific customizations.
+- **Feature parity \& restriction:** Ensure the new composable enforces the same UI constraints as the original XML component, preventing unauthorized style overrides while maintaining the intended flexibility.
+
+Example before migration:
+
+
+
+Example after migration:
+
+
+```kotlin
+@Composable
+fun RoundedBorderlessButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true
+) {
+ TextButton(
+ onClick, modifier
+ .defaultMinSize(minWidth = dimensionResource(R.dimen.min_width))
+ .padding(
+ start = dimensionResource(R.dimen.padding_2),
+ end = dimensionResource(R.dimen.padding_2)
+ ), enabled, shape = RoundedCornerShape(8.dp),
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = MaterialTheme.colorScheme.primary
+ )
+ ) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyMedium.copy(
+ fontFamily = FontFamily.SansSerif,
+ fontWeight = FontWeight.Medium
+ )
+ )
+ }
+}
+```
+
+
+
+*** ** * ** ***
+
+## 5. Output requirements
+
+- Provide the full Kotlin file content.
+- Include necessary imports.
+- Add documentation comments (`/** ... */`) explaining the mapping logic for complex transformations.
\ No newline at end of file
diff --git a/.claude/skills/navigation-3/SKILL.md b/.claude/skills/navigation-3/SKILL.md
new file mode 100644
index 00000000..e32b602e
--- /dev/null
+++ b/.claude/skills/navigation-3/SKILL.md
@@ -0,0 +1,110 @@
+---
+name: navigation-3
+description: Learn how to install and migrate to Jetpack Navigation 3, and how to
+ implement features and patterns such as deep links, multiple backstacks, scenes
+ (dialogs, bottom sheets, list-detail, two-pane, supporting pane), conditional navigation
+ (such as logged-in navigation vs anonymous), returning results from flows, integration
+ with Hilt, ViewModel, Kotlin, and view interoperability.
+license: Complete terms in LICENSE.txt
+metadata:
+ author: Google LLC
+ last-updated: '2026-06-02'
+ keywords:
+ - recipe
+ - Android
+ - Navigation 2
+ - Navigation 3
+ - migration
+ - Compose
+ - guide
+ - dependencies
+ - NavKey
+ - NavHost
+ - NavDisplay
+ - BottomSheet
+ - list-detail
+ - scenes
+ - two-pane
+ - supporting pane
+ - multiple backstacks
+ - dialog
+ - Hilt
+ - ViewModel
+ - View interop.
+---
+
+## Migration guide
+
+- *[Navigation 2 to Navigation 3 migration guide](references/android/guide/navigation/navigation-3/migration-guide.md)*: Step-by-step guide to migrate an Android application from Navigation 2 to Navigation 3, covering dependency updates, route changes, state management, and UI component replacements.
+
+### Requirements
+
+- *[Guide: Migrate to type-safe navigation in Compose](references/android/guide/navigation/type-safe-destinations.md)* : Step-by-step guide to migrating an Android application from string-based navigation to **Type-Safe Navigation** in Jetpack Compose using Jetpack Navigation 2.
+
+## Developer documentation
+
+- \*[Navigation 3](references/android/guide/navigation/navigation-3/index.md). Search documentation for more information on basics, saving and managing navigation state, modularizing navigation code, creating custom layouts using Scenes, animating between destinations, or applying logic or wrappers to destinations.
+
+## Recipes
+
+Code examples showcasing common patterns.
+
+### Basic API usage
+
+- *[Basic](references/android/guide/navigation/navigation-3/recipes/basic.md)*: Shows most basic API usage.
+- *[Saveable back stack](references/android/guide/navigation/navigation-3/recipes/basicsaveable.md)*: Shows basic API usage with a persistent back stack.
+- *[Entry provider DSL](references/android/guide/navigation/navigation-3/recipes/basicdsl.md)*: Shows basic API usage using the entryProvider DSL.
+
+### Common UI
+
+- *[Common UI](references/android/guide/navigation/navigation-3/recipes/common-ui.md)*: Demonstrates how to implement a common navigation UI pattern with a bottom navigation bar and multiple back stacks, where each tab in the navigation bar has its own navigation history.
+
+### Deep links
+
+- *[Basic](references/android/guide/navigation/navigation-3/recipes/deeplinks-basic.md)*: Shows how to parse a deep link URL from an Android Intent into a navigation key.
+- *[Advanced](references/android/guide/navigation/navigation-3/recipes/deeplinks-advanced.md)*: Shows how to handle deep links with a synthetic back stack and correct "Up" navigation behavior.
+
+### Scenes
+
+#### Use built-in Scenes
+
+- *[Dialog](references/android/guide/navigation/navigation-3/recipes/dialog.md)*: Shows how to create a Dialog.
+
+#### Create custom Scenes
+
+- *[BottomSheet](references/android/guide/navigation/navigation-3/recipes/bottomsheet.md)*: Shows how to create a BottomSheet destination.
+- *[List-Detail Scene](references/android/guide/navigation/navigation-3/recipes/scenes-listdetail.md)*: Demonstrates how to implement adaptive list-detail layouts using the Navigation 3 Scenes API.
+- *[Two pane Scene](references/android/guide/navigation/navigation-3/recipes/scenes-twopane.md)*: Demonstrates how to implement adaptive two-pane layouts using the Navigation 3 Scenes API.
+
+### Material Adaptive
+
+- *[Material List-Detail](references/android/guide/navigation/navigation-3/recipes/material-listdetail.md)*: Demonstrates how to implement an adaptive list-detail layout using Material 3 Adaptive.
+- *[Material Supporting Pane](references/android/guide/navigation/navigation-3/recipes/material-supportingpane.md)*: Demonstrates how to implement an adaptive supporting pane layout using Material 3 Adaptive.
+
+### Animations
+
+- *[Animations](references/android/guide/navigation/navigation-3/recipes/animations.md)*: Shows how to override the default animations for all destinations and a single destination.
+
+### Common back stack behavior
+
+- *[Multiple back stacks](references/android/guide/navigation/navigation-3/recipes/multiple-backstacks.md)*: Shows how to create multiple top level routes, each with its own back stack. Top level routes are displayed in a navigation bar allowing users to switch between them. State is retained for each top level route, and the navigation state persists config changes and process death.
+
+### Conditional navigation
+
+- *[Conditional navigation](references/android/guide/navigation/navigation-3/recipes/conditional.md)*: Switch to a different navigation flow when a condition is met. For example, for authentication or first-time user onboarding.
+
+### Architecture
+
+- *[Modularized navigation code (Hilt)](references/android/guide/navigation/navigation-3/recipes/modular-hilt.md)*: Demonstrates how to decouple navigation code into separate modules using Hilt or Dagger for DI.
+- *[Modularized navigation code (Koin)](references/android/guide/navigation/navigation-3/recipes/modular-koin.md)*: Demonstrates how to decouple navigation code into separate modules using Koin for DI.
+
+### Working with ViewModel
+
+#### Passing navigation arguments
+
+- *[Basic ViewModel](references/android/guide/navigation/navigation-3/recipes/passingarguments.md)* : Navigation arguments are passed to a `ViewModel` constructed using `viewModel()`
+
+### Returning results
+
+- *[Returning Results as Events](references/android/guide/navigation/navigation-3/recipes/results-event.md)* : Returning results as events to content in another `NavEntry`
+- *[Returning Results as State](references/android/guide/navigation/navigation-3/recipes/results-state.md)* : Returning results as state stored in a `CompositionLocal`
diff --git a/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/index.md b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/index.md
new file mode 100644
index 00000000..b4cefbfa
--- /dev/null
+++ b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/index.md
@@ -0,0 +1,38 @@
+Navigation 3 is a new navigation library designed to work with Compose. With
+Navigation 3, you have full control over your back stack, and navigating to and
+from destinations is as simple as adding and removing items from a list. It
+creates a flexible app navigation system by providing:
+
+- Conventions for modeling a back stack, where each entry on the back stack represents content that the user has navigated to
+- A UI that automatically updates with back stack changes (including animations)
+- A scope for items in the back stack, allowing state to be retained while an item is in the back stack
+- An adaptive layout system that allows multiple destinations to be displayed at the same time, and allowing seamless switching between those layouts
+- A mechanism for content to communicate with its parent layout (metadata)
+
+At a high level, you implement Navigation 3 in the following ways:
+
+1. Define the content that users can navigate to in your app, each with a unique key, and add a function to resolve that key to the content. See [Resolve keys
+ to content](https://developer.android.com/guide/navigation/navigation-3/basics#resolve-keys).
+2. Create a back stack that keys are pushed onto and removed as users navigate your app. See [Create a back stack](https://developer.android.com/guide/navigation/navigation-3/basics#create-back).
+3. Use a [`NavDisplay`](https://developer.android.com/reference/kotlin/androidx/navigation3/ui/NavDisplay.composable) to display your app's back stack. Whenever the back stack changes, it updates the UI to display relevant content. See [Display
+ the back stack](https://developer.android.com/guide/navigation/navigation-3/basics#display-back).
+4. Modify `NavDisplay`'s [scene strategies](https://developer.android.com/guide/navigation/navigation-3/custom-layouts) as needed to support adaptive layouts and different platforms.
+
+You can see the [full source code](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:navigation3/) for Navigation 3 on AOSP.
+
+## Improvements upon Jetpack Navigation
+
+Navigation 3 improves upon the original Jetpack Navigation API in the following
+ways:
+
+- Provides a simpler integration with Compose
+- Offers you full control of the back stack
+- Makes it possible to create layouts that can read more than one destination from the back stack at the same time, allowing them to adapt to changes in window size and other inputs.
+
+Read more about Navigation 3's principles and API design choices in [this blog
+post](https://android-developers.googleblog.com/2025/05/announcing-jetpack-navigation-3-for-compose.html).
+
+## Code samples
+
+The [recipes repository](https://github.com/android/nav3-recipes) contains examples of how to use the
+Navigation 3 building blocks to solve common navigation challenges.
\ No newline at end of file
diff --git a/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/migration-guide.md b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/migration-guide.md
new file mode 100644
index 00000000..ba86f3ca
--- /dev/null
+++ b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/migration-guide.md
@@ -0,0 +1,498 @@
+To migrate your app from [Navigation 2](https://developer.android.com/guide/navigation) to Navigation 3, follow these steps:
+
+1. Add the Navigation 3 dependencies.
+2. Update your navigation routes to implement the `NavKey` interface.
+3. Create classes to hold and modify your navigation state.
+4. Replace `NavController` with these classes.
+5. Move your destinations from `NavHost`'s `NavGraph` into an `entryProvider`.
+6. Replace `NavHost` with `NavDisplay`.
+7. Remove Navigation 2 dependencies.
+
+> [!IMPORTANT]
+> **Important:** We released an agent skill to help you install and migrate to Jetpack Navigation 3. Try out the skill from the [Android skills repository](https://github.com/android/skills).
+
+
+
+
+## AI Prompt
+
+### Migrate from Navigation 2 to Navigation 3
+
+This prompt will use this guide to migrate to navigation 3.
+
+ Migrate from Navigation 2 to Navigation 3 using the official
+ migration guide.
+
+### Using AI prompts
+
+AI prompts are intended to be used within Gemini in Android Studio.
+
+Learn more about Gemini in Studio here: [https://developer.android.com/studio/gemini/overview](https://developer.android.com/studio/gemini/overview)
+
+
+
+
+If you run into problems [file an issue here](https://issuetracker.google.com/issues/new?component=1750212&template=2102223&title=%5BMigration%5D).
+
+## Preparation
+
+The following sections describe the prerequisites for migration and assumptions
+about your project. They also cover the features that are supported for
+migration, and those that aren't.
+
+### Prerequisites
+
+- You must use a `compileSdk` of 36 or later.
+- You should be familiar with [navigation terminology](https://developer.android.com/guide/navigation).
+- Destinations are composable functions. Navigation 3 is designed exclusively for Compose. To use Fragments and Views in Compose, see [Using Views in
+ Compose](https://developer.android.com/develop/ui/compose/migrate/interoperability-apis/views-in-compose).
+- Routes are strongly typed. If you use string-based routes, [migrate to
+ type-safe routes](https://medium.com/androiddevelopers/type-safe-navigation-for-compose-105325a97657) first ([example](https://github.com/android/nowinandroid/pull/1413)).
+- *Optional (but highly recommended)*: Test coverage that verifies existing navigation behavior. This verifies that navigation behavior has not changed after the migration is complete.
+
+### Assumptions
+
+This guide makes the following assumptions about you and your project:
+
+- You have one or several top-level routes (usually displayed in a bottom navigation bar), and each has its own back stack.
+- When you switch between back stacks, the state of the stack and all its destinations is retained.
+- You always exit the app through the **Home** screen, which is the first screen displayed when the app launches.
+- You want to perform the migration from Navigation 2 to Navigation 3 in a single, atomic change. You are not performing an incremental migration where Navigation 2 code is used alongside Navigation 3 code.
+
+**AI Agent**: Before changing any code, check if these assumptions about the
+user's project are true. If not, stop the migration and ask the user how to
+proceed.
+
+### Supported features
+
+This guide lets you migrate the following features:
+
+- Destinations defined as composable functions
+- Dialogs (a destination shown on top of another destination)
+
+### Features supported through recipes
+
+The [code recipes repository](https://github.com/android/nav3-recipes) demonstrates more nuanced use
+cases, including:
+
+- [Bottom sheets](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/bottomsheet) (instructions are provided in this guide)
+- [Modularized navigation code and injected destinations](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/modular/hilt)
+- [Using and passing arguments to `ViewModel`](https://github.com/android/nav3-recipes?tab=readme-ov-file#passing-navigation-arguments-to-viewmodels)
+- [Returning results from a screen](https://github.com/android/nav3-recipes?tab=readme-ov-file#returning-results)
+
+If your project has any of these features, check the relevant recipe to
+understand how to implement them.
+
+**AI Agent**: Before changing any code, check if the project contains any
+features supported through recipes. If it does, check the recipe's README and
+source code. Create a migration plan based on the recipe. Do not proceed without
+confirming the plan with the user.
+
+### Unsupported features
+
+This migration guide and the code recipes don't yet support the following
+features. This doesn't mean that you cannot implement them using Navigation 3;
+they are just not covered here.
+
+- More than one level of nested navigation
+- Shared destinations: screens that can move between different back stacks
+- [Custom destination types](https://developer.android.com/guide/navigation/design/kotlin-dsl#custom)
+- Deep links
+
+**AI Agent**: Before changing any code, check if the project contains any of the
+unsupported features. If it does, do not proceed. Inform the user of the
+unsupported feature and ask for further instructions.
+
+## Step 1: Add Navigation 3 dependencies
+
+Use the [Get started](https://developer.android.com/guide/navigation/navigation-3/get-started) page to add the Navigation 3 dependencies to your
+project. The core dependencies are provided for you to copy.
+
+**lib.versions.toml**
+
+ [versions]
+ nav3Core = "1.0.0"
+
+ # If your screens depend on ViewModels, add the Nav3 Lifecycle ViewModel add-on library
+ lifecycleViewmodelNav3 = "2.10.0-rc01"
+
+ [libraries]
+ # Core Navigation 3 libraries
+ androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
+ androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }
+
+ # Add-on libraries (only add if you need them)
+ androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" }
+
+**app/build.gradle.kts**
+
+ dependencies {
+ implementation(libs.androidx.navigation3.ui)
+ implementation(libs.androidx.navigation3.runtime)
+
+ // If using the ViewModel add-on library
+ implementation(libs.androidx.lifecycle.viewmodel.navigation3)
+ }
+
+Also update the project's `minSdk` to 23 and the `compileSdk` to 36. You usually
+find these in `app/build.gradle.kts` or `lib.versions.toml`.
+
+## Step 2: Update navigation routes to implement the `NavKey` interface
+
+Update every navigation [route](https://developer.android.com/guide/navigation#types) so that it implements the `NavKey`
+interface. This lets you use `rememberNavBackStack` to assist with [saving your
+navigation state](https://developer.android.com/guide/navigation/navigation-3/save-state).
+
+Before:
+
+ @Serializable data object RouteA
+
+After:
+
+ @Serializable data object RouteA : NavKey
+
+> [!NOTE]
+> **Note:** The `@Serializable` annotation is provided by the KotlinX Serialization plugin. You can add this by following [these project setup steps](https://developer.android.com/guide/navigation/navigation-3/get-started#project-setup).
+
+## Step 3: Create classes to hold and modify your navigation state
+
+### Step 3.1: Create a navigation state holder
+
+Copy the following code into a file named `NavigationState.kt`. Add your package
+name to match your project structure.
+
+ // package com.example.project
+
+ import androidx.compose.runtime.Composable
+ import androidx.compose.runtime.MutableState
+ import androidx.compose.runtime.getValue
+ import androidx.compose.runtime.mutableStateOf
+ import androidx.compose.runtime.remember
+ import androidx.compose.runtime.saveable.rememberSerializable
+ import androidx.compose.runtime.setValue
+ import androidx.compose.runtime.snapshots.SnapshotStateList
+ import androidx.compose.runtime.toMutableStateList
+ import androidx.navigation3.runtime.NavBackStack
+ import androidx.navigation3.runtime.NavEntry
+ import androidx.navigation3.runtime.NavKey
+ import androidx.navigation3.runtime.rememberDecoratedNavEntries
+ import androidx.navigation3.runtime.rememberNavBackStack
+ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
+ import androidx.navigation3.runtime.serialization.NavKeySerializer
+ import androidx.savedstate.compose.serialization.serializers.MutableStateSerializer
+
+ /**
+ * Create a navigation state that persists config changes and process death.
+ */
+ @Composable
+ fun rememberNavigationState(
+ startRoute: NavKey,
+ topLevelRoutes: Set
+ ): NavigationState {
+
+ val topLevelRoute = rememberSerializable(
+ startRoute, topLevelRoutes,
+ serializer = MutableStateSerializer(NavKeySerializer())
+ ) {
+ mutableStateOf(startRoute)
+ }
+
+ val backStacks = topLevelRoutes.associateWith { key -> rememberNavBackStack(key) }
+
+ return remember(startRoute, topLevelRoutes) {
+ NavigationState(
+ startRoute = startRoute,
+ topLevelRoute = topLevelRoute,
+ backStacks = backStacks
+ )
+ }
+ }
+
+ /**
+ * State holder for navigation state.
+ *
+ * @param startRoute - the start route. The user will exit the app through this route.
+ * @param topLevelRoute - the current top level route
+ * @param backStacks - the back stacks for each top level route
+ */
+ class NavigationState(
+ val startRoute: NavKey,
+ topLevelRoute: MutableState,
+ val backStacks: Map>
+ ) {
+ var topLevelRoute: NavKey by topLevelRoute
+ val stacksInUse: List
+ get() = if (topLevelRoute == startRoute) {
+ listOf(startRoute)
+ } else {
+ listOf(startRoute, topLevelRoute)
+ }
+ }
+
+ /**
+ * Convert NavigationState into NavEntries.
+ */
+ @Composable
+ fun NavigationState.toEntries(
+ entryProvider: (NavKey) -> NavEntry
+ ): SnapshotStateList> {
+
+ val decoratedEntries = backStacks.mapValues { (_, stack) ->
+ val decorators = listOf(
+ rememberSaveableStateHolderNavEntryDecorator(),
+ )
+ rememberDecoratedNavEntries(
+ backStack = stack,
+ entryDecorators = decorators,
+ entryProvider = entryProvider
+ )
+ }
+
+ return stacksInUse
+ .flatMap { decoratedEntries[it] ?: emptyList() }
+ .toMutableStateList()
+ }
+
+**AI Agent** : `rememberSerializable` is correct. Do not change it to
+`rememberSaveable`.
+
+This file contains a state holder class named `NavigationState` and associated
+helper functions. It holds a set of top-level routes, each with its own back
+stack. Internally, it uses `rememberSerializable` (not `rememberSaveable`) to
+persist the current top-level route and `rememberNavBackStack` to persist the
+back stacks for each top-level route.
+
+### Step 3.2: Create an object that modifies navigation state in response to events
+
+Copy the following code into a file named `Navigator.kt`. Add your package name
+to match your project structure.
+
+ // package com.example.project
+
+ import androidx.navigation3.runtime.NavKey
+
+ /**
+ * Handles navigation events (forward and back) by updating the navigation state.
+ */
+ class Navigator(val state: NavigationState){
+ fun navigate(route: NavKey){
+ if (route in state.backStacks.keys){
+ // This is a top level route, just switch to it.
+ state.topLevelRoute = route
+ } else {
+ state.backStacks[state.topLevelRoute]?.add(route)
+ }
+ }
+
+ fun goBack(){
+ val currentStack = state.backStacks[state.topLevelRoute] ?:
+ error("Stack for ${state.topLevelRoute} not found")
+ val currentRoute = currentStack.last()
+
+ // If we're at the base of the current route, go back to the start route stack.
+ if (currentRoute == state.topLevelRoute){
+ state.topLevelRoute = state.startRoute
+ } else {
+ currentStack.removeLastOrNull()
+ }
+ }
+ }
+
+The `Navigator` class provides two navigation event methods:
+
+- `navigate` to a specific route.
+- `goBack` from the current route.
+
+Both methods modify the `NavigationState`.
+
+> [!IMPORTANT]
+> **Architecture principles:** These classes follow the principles of [Unidirectional Data Flow](https://developer.android.com/topic/architecture):
+>
+> - The `Navigator` handles navigation events and uses them to update `NavigationState`.
+> - The UI (provided by `NavDisplay`) observes `NavigationState` and reacts to any changes in that state by updating its UI.
+
+### Step 3.3: Create the `NavigationState` and `Navigator`
+
+Create instances of `NavigationState` and `Navigator` with the same scope as
+your `NavController`.
+
+ val navigationState = rememberNavigationState(
+ startRoute = ,
+ topLevelRoutes =
+ )
+
+ val navigator = remember { Navigator(navigationState) }
+
+## Step 4: Replace `NavController`
+
+Replace `NavController` navigation event methods with `Navigator` equivalents.
+
+| **`NavController` field or method** | **`Navigator` equivalent** |
+|---|---|
+| `navigate()` | `navigate()` |
+| `popBackStack()` | `goBack()` |
+
+Replace `NavController` fields with `NavigationState` fields.
+
+| **`NavController` field or method** | **`NavigationState` equivalent** |
+|---|---|
+| `currentBackStack` | `backStacks[topLevelRoute]` |
+| `currentBackStackEntry` `currentBackStackEntryAsState()` `currentBackStackEntryFlow` `currentDestination` | `backStacks[topLevelRoute].last()` |
+| Get the top level route: Traverse up the hierarchy from the current back stack entry to find it. | `topLevelRoute` |
+
+Use `NavigationState.topLevelRoute` to determine the item that is currently
+selected in a navigation bar.
+
+Before:
+
+ val isSelected = navController.currentBackStackEntryAsState().value?.destination.isRouteInHierarchy(key::class)
+
+ fun NavDestination?.isRouteInHierarchy(route: KClass<*>) =
+ this?.hierarchy?.any {
+ it.hasRoute(route)
+ } ?: false
+
+After:
+
+ val isSelected = key == navigationState.topLevelRoute
+
+Verify that you have removed all references to `NavController`, including
+any imports.
+
+## Step 5: Move your destinations from `NavHost`'s `NavGraph` into an `entryProvider`
+
+In Navigation 2, you [define your destinations](https://developer.android.com/guide/navigation/design#compose)
+using the [NavGraphBuilder DSL](https://developer.android.com/guide/navigation/design/kotlin-dsl#navgraphbuilder),
+usually inside `NavHost`'s trailing lambda. It is common to use extension
+functions here as described in [Encapsulate your navigation code](https://developer.android.com/guide/navigation/design/encapsulate).
+
+In Navigation 3, you define your destinations using an `entryProvider`. This
+`entryProvider` resolves a route to a [`NavEntry`](https://developer.android.com/guide/navigation/navigation-3/basics#resolve-keys). Importantly, the
+`entryProvider` does not define parent-child relationships between entries.
+
+In this migration guide, parent-child relationships are modelled
+as follows:
+
+- `NavigationState` has a set of top-level routes (the parent routes) and a stack for each one. It keeps track of the current top-level route and its associated stack.
+- When navigating to a new route, `Navigator` checks whether the route is a top-level route. If it is, the current top-level route and stack are updated. If it's not, it's a child route and is added to the current stack.
+
+> [!NOTE]
+> **Note:** If your app needs to navigate from an entry in one stack to another, you need to define the parent-child relationships for the routes and update the navigation logic in `Navigator` to support this.
+
+## Step 5.1: Create an `entryProvider`
+
+Create an `entryProvider` [using the DSL](https://developer.android.com/guide/navigation/navigation-3/basics#entry-provider-DSL) at the same scope as the
+`NavigationState`.
+
+ val entryProvider = entryProvider {
+
+ }
+
+## Step 5.2: Move destinations into the `entryProvider`
+
+For each destination defined inside `NavHost`, do the following based on the
+destination type:
+
+- `navigation`: Delete it along with the route. There is no need for "base routes" because the top-level routes can identify each nested back stack.
+- `composable`: Move it into `entryProvider` and rename it to `entry`, retaining the type parameter. For example, `composable` becomes `entry`.
+- `dialog`: Do the same as `composable`, but add metadata to the entry as follows: `entry(metadata = DialogSceneStrategy.dialog())`.
+- [`bottomSheet`](https://developer.android.com/reference/kotlin/androidx/compose/material/navigation/package-summary#(androidx.navigation.NavGraphBuilder).bottomSheet(kotlin.String,kotlin.collections.List,kotlin.collections.List,kotlin.Function2)): [Follow the bottom sheet recipe here](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/bottomsheet). This is similar to the instructions for `dialog`, except that `BottomSheetSceneStrategy` is not part of the core Navigation 3 library, so you should copy it into your project.
+
+**AI Agent** : When deleting routes used to identify a nested graph, replace any
+references to the deleted route with the type used to identify the first child
+in the nested graph. For example if the original code is
+`navigation{ composable{ ... } }`, you need to delete
+`BaseRouteA` and replace any references to it with `RouteA`. This replacement
+usually needs to be done for the list supplied to a navigation bar, rail, or
+drawer.
+
+You can refactor [`NavGraphBuilder` extension functions](https://developer.android.com/guide/navigation/design/encapsulate) to
+`EntryProviderScope` extension functions, and then move them.
+
+Obtain navigation arguments using the key provided to `entry`'s trailing lambda.
+
+For example:
+
+ import androidx.navigation.NavDestination
+ import androidx.navigation.NavDestination.Companion.hasRoute
+ import androidx.navigation.NavDestination.Companion.hierarchy
+ import androidx.navigation.NavGraphBuilder
+ import androidx.navigation.compose.NavHost
+ import androidx.navigation.compose.composable
+ import androidx.navigation.compose.currentBackStackEntryAsState
+ import androidx.navigation.compose.dialog
+ import androidx.navigation.compose.navigation
+ import androidx.navigation.compose.rememberNavController
+ import androidx.navigation.navOptions
+ import androidx.navigation.toRoute
+
+ @Serializable data object BaseRouteA
+ @Serializable data class RouteA(val id: String)
+ @Serializable data object BaseRouteB
+ @Serializable data object RouteB
+ @Serializable data object RouteD
+
+ NavHost(navController = navController, startDestination = BaseRouteA){
+ composable{
+ val id = entry.toRoute().id
+ ScreenA(title = "Screen has ID: $id")
+ }
+ featureBSection()
+ dialog{ ScreenD() }
+ }
+
+ fun NavGraphBuilder.featureBSection() {
+ navigation(startDestination = RouteB) {
+ composable { ScreenB() }
+ }
+ }
+
+becomes:
+
+ import androidx.navigation3.runtime.EntryProviderScope
+ import androidx.navigation3.runtime.NavKey
+ import androidx.navigation3.runtime.entryProvider
+ import androidx.navigation3.scene.DialogSceneStrategy
+
+ @Serializable data class RouteA(val id: String) : NavKey
+ @Serializable data object RouteB : NavKey
+ @Serializable data object RouteD : NavKey
+
+ val entryProvider = entryProvider {
+ entry{ key -> ScreenA(title = "Screen has ID: ${key.id}") }
+ featureBSection()
+ entry(metadata = DialogSceneStrategy.dialog()){ ScreenD() }
+ }
+
+ fun EntryProviderScope.featureBSection() {
+ entry { ScreenB() }
+ }
+
+## Step 6: Replace `NavHost` with `NavDisplay`
+
+Replace `NavHost` with `NavDisplay`.
+
+- Delete `NavHost` and replace it with `NavDisplay`.
+- Specify `entries = navigationState.toEntries(entryProvider)` as a parameter. This converts the navigation state into the entries that `NavDisplay` shows using the `entryProvider`.
+- Connect `NavDisplay.onBack` to `navigator.goBack()`. This causes `navigator` to update the navigation state when `NavDisplay`'s built-in back handler completes.
+- If you have dialog destinations, add `DialogSceneStrategy` to `NavDisplay`'s `sceneStrategies` parameter.
+
+For example:
+
+ import androidx.navigation3.ui.NavDisplay
+
+ NavDisplay(
+ entries = navigationState.toEntries(entryProvider),
+ onBack = { navigator.goBack() },
+ sceneStrategies = remember { listOf(DialogSceneStrategy()) }
+ )
+
+## Step 7: Remove Navigation 2 dependencies
+
+Remove all Navigation 2 imports and library dependencies.
+
+## Summary
+
+Congratulations! Your project is now migrated to Navigation 3. If you or your AI
+agent has run into any problems using this guide, [file a bug
+here](https://issuetracker.google.com/issues/new?component=1750212&template=2102223&title=%5BMigration%5D).
\ No newline at end of file
diff --git a/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/animations.md b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/animations.md
new file mode 100644
index 00000000..5e15bd46
--- /dev/null
+++ b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/animations.md
@@ -0,0 +1,147 @@
+# Animations Recipe
+
+This recipe shows how to override the default animations at the `NavDisplay` level, and at the individual destination level.
+
+## How it works
+
+The `NavDisplay` composable takes `transitionSpec`, `popTransitionSpec`, and `predictivePopTransitionSpec` parameters to define the animations for forward, backward, and predictive back navigation respectively. These animations will be applied to all destinations by default.
+
+In this example, we use `slideInHorizontally` and `slideOutHorizontally` to create a sliding animation for forward and backward navigation.
+
+It is also possible to override these animations for a specific destination by providing a different `transitionSpec` and `popTransitionSpec` to the `entry` composable. In this recipe, `ScreenC` has a custom vertical slide animation.
+[ Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/animations)
+
+```
+package com.example.nav3recipes.animations
+
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.animation.togetherWith
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.lifecycle.compose.dropUnlessResumed
+import androidx.navigation3.runtime.NavKey
+import androidx.navigation3.runtime.entryProvider
+import androidx.navigation3.runtime.metadata
+import androidx.navigation3.runtime.rememberNavBackStack
+import androidx.navigation3.ui.NavDisplay
+import com.example.nav3recipes.content.ContentGreen
+import com.example.nav3recipes.content.ContentMauve
+import com.example.nav3recipes.content.ContentOrange
+import com.example.nav3recipes.ui.setEdgeToEdgeConfig
+import kotlinx.serialization.Serializable
+
+
+@Serializable
+private data object ScreenA : NavKey
+
+@Serializable
+private data object ScreenB : NavKey
+
+@Serializable
+private data object ScreenC : NavKey
+
+
+class AnimatedActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ setEdgeToEdgeConfig()
+ super.onCreate(savedInstanceState)
+ setContent {
+
+ val backStack = rememberNavBackStack(ScreenA)
+
+ NavDisplay(
+ backStack = backStack,
+ onBack = { backStack.removeLastOrNull() },
+ entryProvider = entryProvider {
+ entry {
+ ContentOrange("This is Screen A") {
+ Button(onClick = dropUnlessResumed { backStack.add(ScreenB) }) {
+ Text("Go to Screen B")
+ }
+ }
+ }
+ entry {
+ ContentMauve("This is Screen B") {
+ Button(onClick = dropUnlessResumed { backStack.add(ScreenC) }) {
+ Text("Go to Screen C")
+ }
+ }
+ }
+ entry(
+ metadata = metadata {
+ // Slide new content up, keeping the old content in place underneath
+ put(NavDisplay.TransitionKey) {
+ slideInVertically(
+ initialOffsetY = { it },
+ animationSpec = tween(1000)
+ ) togetherWith ExitTransition.KeepUntilTransitionsFinished
+ }
+
+ // Slide old content down, revealing the new content in place underneath
+ put(NavDisplay.PopTransitionKey) {
+ EnterTransition.None togetherWith
+ slideOutVertically(
+ targetOffsetY = { it },
+ animationSpec = tween(1000)
+ )
+ }
+
+ // Slide old content down, revealing the new content in place underneath
+ put(NavDisplay.PredictivePopTransitionKey) {
+ EnterTransition.None togetherWith
+ slideOutVertically(
+ targetOffsetY = { it },
+ animationSpec = tween(1000)
+ )
+ }
+ }
+ ) {
+ ContentGreen("This is Screen C")
+ }
+ },
+ transitionSpec = {
+ // Slide in from right when navigating forward
+ slideInHorizontally(
+ initialOffsetX = { it },
+ animationSpec = tween(1000)
+ ) togetherWith slideOutHorizontally(
+ targetOffsetX = { -it },
+ animationSpec = tween(1000)
+ )
+ },
+ popTransitionSpec = {
+ // Slide in from left when navigating back
+ slideInHorizontally(
+ initialOffsetX = { -it },
+ animationSpec = tween(1000)
+ ) togetherWith slideOutHorizontally(
+ targetOffsetX = { it },
+ animationSpec = tween(1000)
+ )
+ },
+ predictivePopTransitionSpec = {
+ // Slide in from left when navigating back
+ slideInHorizontally(
+ initialOffsetX = { -it },
+ animationSpec = tween(1000)
+ ) togetherWith slideOutHorizontally(
+ targetOffsetX = { it },
+ animationSpec = tween(1000)
+ )
+ }
+ )
+ }
+ }
+}
+```
\ No newline at end of file
diff --git a/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/basic.md b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/basic.md
new file mode 100644
index 00000000..087d5cf9
--- /dev/null
+++ b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/basic.md
@@ -0,0 +1,89 @@
+# Basic Recipe
+
+This recipe shows a basic example of how to use the Navigation 3 API with two screens.
+
+## How it works
+
+This example defines two routes: `RouteA` and `RouteB`. `RouteA` is a `data object` representing a simple screen, while `RouteB` is a `data class` that takes an `id` as a parameter.
+
+A `mutableStateListOf` is used to manage the navigation back stack.
+
+The `NavDisplay` composable is used to display the current screen. Its `entryProvider` parameter is a lambda that takes a route from the back stack and returns a `NavEntry`. Inside the `entryProvider`, a `when` statement is used to determine which composable to display based on the route.
+
+To navigate from `RouteA` to `RouteB`, we simply add a `RouteB` instance to the back stack. The `id` is passed as an argument to the `RouteB` data class.
+[ Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/basic)
+
+```
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.nav3recipes.basic
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.remember
+import androidx.lifecycle.compose.dropUnlessResumed
+import androidx.navigation3.runtime.NavEntry
+import androidx.navigation3.ui.NavDisplay
+import com.example.nav3recipes.content.ContentBlue
+import com.example.nav3recipes.content.ContentGreen
+import com.example.nav3recipes.ui.setEdgeToEdgeConfig
+
+private data object RouteA
+
+private data class RouteB(val id: String)
+
+class BasicActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ setEdgeToEdgeConfig()
+ super.onCreate(savedInstanceState)
+ setContent {
+ val backStack = remember { mutableStateListOf(RouteA) }
+
+ NavDisplay(
+ backStack = backStack,
+ onBack = { backStack.removeLastOrNull() },
+ entryProvider = { key ->
+ when (key) {
+ is RouteA -> NavEntry(key) {
+ ContentGreen("Welcome to Nav3") {
+ Button(onClick = dropUnlessResumed {
+ backStack.add(RouteB("123"))
+ }) {
+ Text("Click to navigate")
+ }
+ }
+ }
+
+ is RouteB -> NavEntry(key) {
+ ContentBlue("Route id: ${key.id} ")
+ }
+
+ else -> {
+ error("Unknown route: $key")
+ }
+ }
+ }
+ )
+ }
+ }
+}
+```
\ No newline at end of file
diff --git a/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/basicdsl.md b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/basicdsl.md
new file mode 100644
index 00000000..2067c086
--- /dev/null
+++ b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/basicdsl.md
@@ -0,0 +1,85 @@
+# Basic DSL Recipe
+
+This recipe shows a basic example of how to use the Navigation 3 API with two screens, using the `entryProvider` DSL and a persistent back stack.
+
+## How it works
+
+This example is similar to the basic recipe, but with a few key differences:
+
+1. **Persistent Back Stack** : It uses `rememberNavBackStack(RouteA)` to create and remember the back stack. This makes the back stack persistent across configuration changes (e.g., screen rotation). To use `rememberNavBackStack`, the navigation keys must be serializable, which is why `RouteA` and `RouteB` are annotated with `@Serializable` and implement the `NavKey` interface.
+
+2. **`entryProvider` DSL** : Instead of a `when` statement, this example uses the `entryProvider` DSL to define the content for each route. The `entry` function is used to associate a route type with its composable content.
+
+The navigation logic remains the same: to navigate from `RouteA` to `RouteB`, we add a `RouteB` instance to the back stack.
+[ Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/basicdsl)
+
+```
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.nav3recipes.basicdsl
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.lifecycle.compose.dropUnlessResumed
+import androidx.navigation3.runtime.NavKey
+import androidx.navigation3.runtime.entryProvider
+import androidx.navigation3.runtime.rememberNavBackStack
+import androidx.navigation3.ui.NavDisplay
+import com.example.nav3recipes.content.ContentBlue
+import com.example.nav3recipes.content.ContentGreen
+import com.example.nav3recipes.ui.setEdgeToEdgeConfig
+import kotlinx.serialization.Serializable
+
+@Serializable
+private data object RouteA : NavKey
+
+@Serializable
+private data class RouteB(val id: String) : NavKey
+
+class BasicDslActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ setEdgeToEdgeConfig()
+ super.onCreate(savedInstanceState)
+ setContent {
+ val backStack = rememberNavBackStack(RouteA)
+
+ NavDisplay(
+ backStack = backStack,
+ onBack = { backStack.removeLastOrNull() },
+ entryProvider = entryProvider {
+ entry {
+ ContentGreen("Welcome to Nav3") {
+ Button(onClick = dropUnlessResumed {
+ backStack.add(RouteB("123"))
+ }) {
+ Text("Click to navigate")
+ }
+ }
+ }
+ entry { key ->
+ ContentBlue("Route id: ${key.id} ")
+ }
+ }
+ )
+ }
+ }
+}
+```
\ No newline at end of file
diff --git a/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/basicsaveable.md b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/basicsaveable.md
new file mode 100644
index 00000000..fd2a72d1
--- /dev/null
+++ b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/basicsaveable.md
@@ -0,0 +1,90 @@
+# Basic Saveable Recipe
+
+This recipe shows a basic example of how to create a persistent back stack that survives configuration changes.
+
+## How it works
+
+To make the back stack persistent, we use the `rememberNavBackStack` function. This function creates and remembers the back stack across configuration changes (e.g., screen rotation).
+
+A requirement for using `rememberNavBackStack` is that the navigation keys (routes) must be serializable. In this example, `RouteA` and `RouteB` are annotated with `@Serializable` and implement the `NavKey` interface.
+
+This example uses a `when` statement within the `entryProvider` to map routes to their corresponding composables, but it could also be used with the `entryProvider` DSL.
+[ Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/basicsaveable)
+
+```
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.nav3recipes.basicsaveable
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.lifecycle.compose.dropUnlessResumed
+import androidx.navigation3.runtime.NavEntry
+import androidx.navigation3.runtime.NavKey
+import androidx.navigation3.runtime.rememberNavBackStack
+import androidx.navigation3.ui.NavDisplay
+import com.example.nav3recipes.content.ContentBlue
+import com.example.nav3recipes.content.ContentGreen
+import com.example.nav3recipes.ui.setEdgeToEdgeConfig
+import kotlinx.serialization.Serializable
+
+@Serializable
+private data object RouteA : NavKey
+
+@Serializable
+private data class RouteB(val id: String) : NavKey
+
+class BasicSaveableActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ setEdgeToEdgeConfig()
+ super.onCreate(savedInstanceState)
+ setContent {
+ val backStack = rememberNavBackStack(RouteA)
+
+ NavDisplay(
+ backStack = backStack,
+ onBack = { backStack.removeLastOrNull() },
+ entryProvider = { key ->
+ when (key) {
+ is RouteA -> NavEntry(key) {
+ ContentGreen("Welcome to Nav3") {
+ Button(onClick = dropUnlessResumed {
+ backStack.add(RouteB("123"))
+ }) {
+ Text("Click to navigate")
+ }
+ }
+ }
+
+ is RouteB -> NavEntry(key) {
+ ContentBlue("Route id: ${key.id} ")
+ }
+
+ else -> {
+ error("Unknown route: $key")
+ }
+ }
+ }
+ )
+ }
+ }
+}
+```
\ No newline at end of file
diff --git a/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/bottomsheet.md b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/bottomsheet.md
new file mode 100644
index 00000000..aa6b456a
--- /dev/null
+++ b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/bottomsheet.md
@@ -0,0 +1,195 @@
+# Bottom Sheet Recipe
+
+This recipe demonstrates how to display a destination as a modal bottom sheet.
+
+## How it works
+
+To show a destination as a bottom sheet, you need to do two things:
+
+1. **Use `BottomSheetSceneStrategy`** : Create an instance of `BottomSheetSceneStrategy` and pass it to the `sceneStrategy` parameter of the `NavDisplay` composable.
+
+2. **Add metadata to the destination** : For the destination that you want to display as a bottom sheet, add `BottomSheetSceneStrategy.bottomSheet()` to its metadata. This is done in the `entry` function.
+
+In this example, `RouteB` is configured to be a bottom sheet. When you navigate from `RouteA` to `RouteB`, `RouteB` will be displayed in a modal bottom sheet that slides up from the bottom of the screen.
+
+The content of the bottom sheet can be styled as needed. In this recipe, the content is clipped to have rounded corners.
+
+For more information, see the official documentation on [custom layouts](https://developer.android.com/guide/navigation/navigation-3/custom-layouts).
+[ Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/bottomsheet)
+
+```
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.nav3recipes.bottomsheet
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Text
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.dropUnlessResumed
+import androidx.navigation3.runtime.NavKey
+import androidx.navigation3.runtime.entryProvider
+import androidx.navigation3.runtime.rememberNavBackStack
+import androidx.navigation3.ui.NavDisplay
+import com.example.nav3recipes.content.ContentBlue
+import com.example.nav3recipes.content.ContentGreen
+import com.example.nav3recipes.ui.setEdgeToEdgeConfig
+import kotlinx.serialization.Serializable
+
+@Serializable
+private data object RouteA : NavKey
+
+@Serializable
+private data class RouteB(val id: String) : NavKey
+
+class BottomSheetActivity : ComponentActivity() {
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ override fun onCreate(savedInstanceState: Bundle?) {
+ setEdgeToEdgeConfig()
+ super.onCreate(savedInstanceState)
+ setContent {
+ val backStack = rememberNavBackStack(RouteA)
+ val bottomSheetStrategy = remember { BottomSheetSceneStrategy() }
+
+ NavDisplay(
+ backStack = backStack,
+ onBack = { backStack.removeLastOrNull() },
+ sceneStrategies = listOf(bottomSheetStrategy),
+ entryProvider = entryProvider {
+ entry {
+ ContentGreen("Welcome to Nav3") {
+ Button(onClick = dropUnlessResumed {
+ backStack.add(RouteB("123"))
+ }) {
+ Text("Click to open bottom sheet")
+ }
+ }
+ }
+ entry(
+ metadata = BottomSheetSceneStrategy.bottomSheet()
+ ) { key ->
+ ContentBlue(
+ title = "Route id: ${key.id}",
+ modifier = Modifier.clip(
+ shape = RoundedCornerShape(16.dp)
+ )
+ )
+ }
+ }
+ )
+ }
+ }
+}
+```
+
+```
+package com.example.nav3recipes.bottomsheet
+
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.ModalBottomSheetProperties
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.compose.rememberLifecycleOwner
+import androidx.navigation3.runtime.NavEntry
+import androidx.navigation3.runtime.NavMetadataKey
+import androidx.navigation3.runtime.get
+import androidx.navigation3.runtime.metadata
+import androidx.navigation3.scene.OverlayScene
+import androidx.navigation3.scene.Scene
+import androidx.navigation3.scene.SceneStrategy
+import androidx.navigation3.scene.SceneStrategyScope
+import com.example.nav3recipes.bottomsheet.BottomSheetSceneStrategy.Companion.bottomSheet
+
+/** An [OverlayScene] that renders an [entry] within a [ModalBottomSheet]. */
+@OptIn(ExperimentalMaterial3Api::class)
+internal data class BottomSheetScene(
+ override val key: T,
+ override val previousEntries: List>,
+ override val overlaidEntries: List>,
+ private val entry: NavEntry,
+ private val modalBottomSheetProperties: ModalBottomSheetProperties,
+ private val onBack: () -> Unit,
+) : OverlayScene {
+
+ override val entries: List> = listOf(entry)
+
+ override val content: @Composable (() -> Unit) = {
+ val lifecycleOwner = rememberLifecycleOwner()
+ ModalBottomSheet(
+ onDismissRequest = onBack,
+ properties = modalBottomSheetProperties,
+ ) {
+ CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) {
+ entry.Content()
+ }
+ }
+ }
+}
+
+/**
+ * A [SceneStrategy] that displays entries that have added [bottomSheet] to their [NavEntry.metadata]
+ * within a [ModalBottomSheet] instance.
+ *
+ * This strategy should always be added before any non-overlay scene strategies.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+class BottomSheetSceneStrategy : SceneStrategy {
+
+ override fun SceneStrategyScope.calculateScene(entries: List>): Scene? {
+ val lastEntry = entries.lastOrNull() ?: return null
+ val bottomSheetProperties = lastEntry.metadata[BottomSheetKey] ?: return null
+ return bottomSheetProperties.let { properties ->
+ @Suppress("UNCHECKED_CAST")
+ BottomSheetScene(
+ key = lastEntry.contentKey as T,
+ previousEntries = entries.dropLast(1),
+ overlaidEntries = entries.dropLast(1),
+ entry = lastEntry,
+ modalBottomSheetProperties = properties,
+ onBack = onBack
+ )
+ }
+ }
+
+ companion object {
+ /**
+ * Function to be called on the [NavEntry.metadata] to mark this entry as something that
+ * should be displayed within a [ModalBottomSheet].
+ *
+ * @param modalBottomSheetProperties properties that should be passed to the containing
+ * [ModalBottomSheet].
+ */
+ fun bottomSheet(modalBottomSheetProperties: ModalBottomSheetProperties = ModalBottomSheetProperties()) =
+ metadata {
+ put(BottomSheetKey, modalBottomSheetProperties)
+ }
+
+ object BottomSheetKey : NavMetadataKey
+ }
+
+}
+```
\ No newline at end of file
diff --git a/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/common-ui.md b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/common-ui.md
new file mode 100644
index 00000000..d49b399e
--- /dev/null
+++ b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/common-ui.md
@@ -0,0 +1,200 @@
+# Common UI Recipe
+
+This recipe demonstrates how to implement a common navigation UI pattern with a bottom navigation bar and multiple back stacks, where each tab in the navigation bar has its own navigation history.
+
+## How it works
+
+This example has three top-level destinations: `Home`, `ChatList`, and `Camera`. The `ChatList` destination also has a sub-route, `ChatDetail`.
+
+### `TopLevelBackStack`
+
+The core of this recipe is the `TopLevelBackStack` class, which is responsible for managing the navigation state. It works as follows:
+
+- It maintains a separate back stack for each top-level destination (tab).
+- It keeps track of the currently selected top-level destination.
+- It provides a single, flattened back stack that can be used by the `NavDisplay` composable. This flattened back stack is a combination of the individual back stacks of all the tabs.
+
+### UI Structure
+
+The UI is built using a `Scaffold` composable, with a `NavigationBar` as the `bottomBar`.
+
+- The `NavigationBar` displays an item for each top-level destination. When an item is clicked, it calls `topLevelBackStack.addTopLevel` to switch to the corresponding tab, preserving the navigation history of each tab.
+- The `NavDisplay` composable is placed in the content area of the `Scaffold`. It is responsible for displaying the current screen based on the flattened back stack provided by `TopLevelBackStack`.
+
+This approach allows for a common navigation pattern where users can switch between different sections of the app, and each section maintains its own navigation history.
+
+### State Preservation
+
+It's important to note how the navigation state is managed in this recipe. When a user navigates away from a top-level destination (e.g., by pressing the back button until they return to a previous tab), the entire navigation history for that destination is cleared. The state is not saved. When the user returns to that tab later, they will start from its initial screen.
+
+**Note** : In this example, the `Home` route can move above the `ChatList` and `Camera` routes, meaning navigating back from `Home` doesn't necessarily leave the app. The app will exit when the user goes back from a single remaining top level route in the back stack.
+[ Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/commonui)
+
+```
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.nav3recipes.commonui
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Face
+import androidx.compose.material.icons.filled.Home
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshots.SnapshotStateList
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.lifecycle.compose.dropUnlessResumed
+import androidx.navigation3.runtime.entryProvider
+import androidx.navigation3.ui.NavDisplay
+import com.example.nav3recipes.content.ContentBlue
+import com.example.nav3recipes.content.ContentGreen
+import com.example.nav3recipes.content.ContentPurple
+import com.example.nav3recipes.content.ContentRed
+import com.example.nav3recipes.ui.setEdgeToEdgeConfig
+
+private sealed interface TopLevelRoute {
+ val icon: ImageVector
+}
+private data object Home : TopLevelRoute { override val icon = Icons.Default.Home }
+private data object ChatList : TopLevelRoute { override val icon = Icons.Default.Face }
+private data object ChatDetail
+private data object Camera : TopLevelRoute { override val icon = Icons.Default.PlayArrow }
+
+private val TOP_LEVEL_ROUTES : List = listOf(Home, ChatList, Camera)
+
+class CommonUiActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ setEdgeToEdgeConfig()
+ super.onCreate(savedInstanceState)
+ setContent {
+ val topLevelBackStack = remember { TopLevelBackStack(Home) }
+
+ Scaffold(
+ bottomBar = {
+ NavigationBar {
+ TOP_LEVEL_ROUTES.forEach { topLevelRoute ->
+
+ val isSelected = topLevelRoute == topLevelBackStack.topLevelKey
+ NavigationBarItem(
+ selected = isSelected,
+ onClick = {
+ topLevelBackStack.addTopLevel(topLevelRoute)
+ },
+ icon = {
+ Icon(
+ imageVector = topLevelRoute.icon,
+ contentDescription = null
+ )
+ }
+ )
+ }
+ }
+ }
+ ) { _ ->
+ NavDisplay(
+ backStack = topLevelBackStack.backStack,
+ onBack = { topLevelBackStack.removeLast() },
+ entryProvider = entryProvider {
+ entry{
+ ContentRed("Home screen")
+ }
+ entry{
+ ContentGreen("Chat list screen"){
+ Button(onClick = dropUnlessResumed {
+ topLevelBackStack.add(ChatDetail)
+ }) {
+ Text("Go to conversation")
+ }
+ }
+ }
+ entry{
+ ContentBlue("Chat detail screen")
+ }
+ entry{
+ ContentPurple("Camera screen")
+ }
+ },
+ )
+ }
+ }
+ }
+}
+
+class TopLevelBackStack(startKey: T) {
+
+ // Maintain a stack for each top level route
+ private var topLevelStacks : LinkedHashMap> = linkedMapOf(
+ startKey to mutableStateListOf(startKey)
+ )
+
+ // Expose the current top level route for consumers
+ var topLevelKey by mutableStateOf(startKey)
+ private set
+
+ // Expose the back stack so it can be rendered by the NavDisplay
+ val backStack = mutableStateListOf(startKey)
+
+ private fun updateBackStack() =
+ backStack.apply {
+ clear()
+ addAll(topLevelStacks.flatMap { it.value })
+ }
+
+ fun addTopLevel(key: T){
+
+ // If the top level doesn't exist, add it
+ if (topLevelStacks[key] == null){
+ topLevelStacks.put(key, mutableStateListOf(key))
+ } else {
+ // Otherwise just move it to the end of the stacks
+ topLevelStacks.apply {
+ remove(key)?.let {
+ put(key, it)
+ }
+ }
+ }
+ topLevelKey = key
+ updateBackStack()
+ }
+
+ fun add(key: T){
+ topLevelStacks[topLevelKey]?.add(key)
+ updateBackStack()
+ }
+
+ fun removeLast(){
+ val removedKey = topLevelStacks[topLevelKey]?.removeLastOrNull()
+ // If the removed key was a top level key, remove the associated top level stack
+ topLevelStacks.remove(removedKey)
+ topLevelKey = topLevelStacks.keys.last()
+ updateBackStack()
+ }
+}
+
+```
\ No newline at end of file
diff --git a/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/conditional.md b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/conditional.md
new file mode 100644
index 00000000..60848b3e
--- /dev/null
+++ b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/conditional.md
@@ -0,0 +1,230 @@
+# Conditional Navigation Recipe
+
+This recipe demonstrates how to implement conditional navigation, where certain destinations are only accessible if a condition is met (in this case, if the user is logged in).
+
+## How it works
+
+This example has a `Profile` destination that requires the user to be logged in. If the user is not logged in and attempts to navigate to `Profile`, they are redirected to a `Login` screen. After a successful login, they are automatically navigated to the `Profile` screen.
+
+### `AppBackStack`
+
+The core of this recipe is the custom `AppBackStack` class, which encapsulates the logic for conditional navigation.
+
+- **`RequiresLogin` interface** : A marker interface, `RequiresLogin`, is used to identify destinations that require the user to be logged in. The `Profile` destination implements this interface.
+
+- **Redirecting to Login** : When the `add` function is called with a destination that implements `RequiresLogin` and the user is not logged in, `AppBackStack` stores the intended destination and adds the `Login` route to the back stack instead.
+
+- **Handling Login** : When the `login` function is called, it sets the user's status to logged in. If there is a stored destination that the user was trying to access, it adds that destination to the back stack and removes the `Login` screen.
+
+- **Handling Logout** : When the `logout` function is called, it sets the user's status to logged out and removes any destinations from the back stack that require the user to be logged in.
+
+This approach provides a clean way to handle conditional navigation by centralizing the logic in a custom back stack implementation.
+[ Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/conditional)
+
+```
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.nav3recipes.conditional
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.saveable.rememberSerializable
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.compose.dropUnlessResumed
+import androidx.navigation3.runtime.NavBackStack
+import androidx.navigation3.runtime.NavKey
+import androidx.navigation3.runtime.entryProvider
+import androidx.navigation3.runtime.serialization.NavBackStackSerializer
+import androidx.navigation3.runtime.serialization.NavKeySerializer
+import androidx.navigation3.ui.NavDisplay
+import com.example.nav3recipes.content.ContentBlue
+import com.example.nav3recipes.content.ContentGreen
+import com.example.nav3recipes.content.ContentYellow
+import com.example.nav3recipes.ui.setEdgeToEdgeConfig
+import kotlinx.serialization.Serializable
+
+
+/**
+ * Class for representing navigation keys in the app.
+ *
+ * Note: We use a sealed class because KotlinX Serialization handles
+ * polymorphic serialization of sealed classes automatically.
+ *
+ * @param requiresLogin - true if the navigation key requires that the user is logged in
+ * to navigate to it
+ */
+@Serializable
+sealed class ConditionalNavKey(val requiresLogin: Boolean = false) : NavKey
+
+/**
+ * Key representing home screen
+ */
+@Serializable
+private data object Home : ConditionalNavKey()
+
+/**
+ * Key representing profile screen that is only accessible once the user has logged in
+ */
+@Serializable
+private data object Profile : ConditionalNavKey(requiresLogin = true)
+
+/**
+ * Key representing login screen
+ *
+ * @param redirectToKey - navigation key to redirect to after successful login
+ */
+@Serializable
+private data class Login(
+ val redirectToKey: ConditionalNavKey? = null
+) : ConditionalNavKey()
+
+class ConditionalActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ setEdgeToEdgeConfig()
+ super.onCreate(savedInstanceState)
+ setContent {
+
+ val backStack = rememberNavBackStack(Home)
+ var isLoggedIn by rememberSaveable {
+ mutableStateOf(false)
+ }
+ val navigator = remember {
+ Navigator(
+ backStack = backStack,
+ onNavigateToRestrictedKey = { redirectToKey -> Login(redirectToKey) },
+ isLoggedIn = { isLoggedIn }
+ )
+ }
+
+ NavDisplay(
+ backStack = backStack,
+ onBack = { navigator.goBack() },
+ entryProvider = entryProvider {
+ entry {
+ ContentGreen("Welcome to Nav3. Logged in? ${isLoggedIn}") {
+ Column {
+ Button(onClick = dropUnlessResumed { navigator.navigate(Profile) }) {
+ Text("Profile")
+ }
+ Button(onClick = dropUnlessResumed { navigator.navigate(Login()) }) {
+ Text("Login")
+ }
+ }
+ }
+ }
+ entry {
+ ContentBlue("Profile screen (only accessible once logged in)") {
+ Button(onClick = dropUnlessResumed {
+ isLoggedIn = false
+ navigator.navigate(Home)
+ }) {
+ Text("Logout")
+ }
+ }
+ }
+ entry { key ->
+ ContentYellow("Login screen. Logged in? $isLoggedIn") {
+ Button(onClick = dropUnlessResumed {
+ isLoggedIn = true
+ key.redirectToKey?.let { targetKey ->
+ backStack.remove(key)
+ navigator.navigate(targetKey)
+ }
+ }) {
+ Text("Login")
+ }
+ }
+ }
+ }
+ )
+ }
+ }
+}
+
+
+// An overload of `rememberNavBackStack` that returns a subtype of `NavKey`.
+// See https://issuetracker.google.com/issues/463382671 for a discussion of this function
+@Composable
+fun rememberNavBackStack(vararg elements: T): NavBackStack {
+ return rememberSerializable(
+ serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer())
+ ) {
+ NavBackStack(*elements)
+ }
+}
+```
+
+```
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.nav3recipes.conditional
+
+import androidx.navigation3.runtime.NavBackStack
+
+/**
+ * Provides navigation events with built-in support for conditional access. If the user attempts to
+ * navigate to a [ConditionalNavKey] that requires login ([ConditionalNavKey.requiresLogin] is true)
+ * but is not currently logged in, the Navigator will redirect the user to a login key.
+ *
+ * @property backStack The back stack that is modified by this class
+ * @property onNavigateToRestrictedKey A lambda that is called when the user attempts to navigate
+ * to a key that requires login. This should return the key that represents the login screen. The
+ * user's target key is supplied as a parameter so that after successful login the user can be
+ * redirected to their target destination.
+ * @property isLoggedIn A lambda that returns whether the user is logged in.
+ */
+class Navigator(
+ private val backStack: NavBackStack,
+ private val onNavigateToRestrictedKey: (targetKey: ConditionalNavKey?) -> ConditionalNavKey,
+ private val isLoggedIn: () -> Boolean,
+) {
+ fun navigate(key: ConditionalNavKey) {
+ if (key.requiresLogin && !isLoggedIn()) {
+ val loginKey = onNavigateToRestrictedKey(key)
+ backStack.add(loginKey)
+ } else {
+ backStack.add(key)
+ }
+ }
+
+ fun goBack() = backStack.removeLastOrNull()
+}
+```
\ No newline at end of file
diff --git a/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-advanced.md b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-advanced.md
new file mode 100644
index 00000000..f2412918
--- /dev/null
+++ b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-advanced.md
@@ -0,0 +1,155 @@
+# Deep Link Advanced Recipe
+
+This recipe demonstrates how to apply the principles of navigation in the context of deep links by
+managing a synthetic backStack and Task stacks.
+
+# Recipe Structure
+
+This recipe simulates a real-world scenario where "App A" deeplinks
+into "App B".
+
+"App A" is simulated by the module [com.example.nav3recipes.deeplink.advanced](https://developer.android.com/app/src/main/java/com/example/nav3recipes/deeplink/advanced), which
+contains the `CreateAdvancedDeepLinkActivity` that allows you to create a deeplink intent and
+trigger that in either the existing Task, or in a new Task.
+
+"App B" is simulated by the module [advanceddeeplinkapp](https://developer.android.com/advanceddeeplinkapp/src/main/java/com/example/nav3recipes/deeplink/advanced), which contains
+the MainActivity that you deeplink into. That module shows you how to build a synthetic backStack
+and how to manage the Task stack properly in order to support both Back and Up buttons.
+
+# Core implementation
+
+The core helper functions for navigateUp and building synthetic backStack can be
+found [here](https://developer.android.com/static/advanceddeeplinkapp/src/main/java/com/example/nav3recipes/deeplink/advanced/util/DeepLinkBackStackUtil.kt)
+
+# Further Read
+
+Check out the [deep link guide](https://developer.android.com/docs/deeplink-guide) for a
+comprehensive guide on Deep linking principles and how to apply them in Navigation 3.
+[ Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/deeplink/advanced)
+
+```
+package com.example.nav3recipes.deeplink.advanced
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.core.net.toUri
+import androidx.lifecycle.compose.dropUnlessResumed
+import com.example.nav3recipes.common.deeplink.EntryScreen
+import com.example.nav3recipes.common.deeplink.LIST_FIRST_NAMES
+import com.example.nav3recipes.common.deeplink.LIST_LOCATIONS
+import com.example.nav3recipes.common.deeplink.MenuDropDown
+import com.example.nav3recipes.common.deeplink.PaddedButton
+import com.example.nav3recipes.common.deeplink.TextContent
+import com.example.nav3recipes.ui.setEdgeToEdgeConfig
+
+internal const val ADVANCED_PATH_BASE = "https://www.nav3deeplink.com"
+
+/**
+ * The recipe entry point that allows users to create a deep link and make a request with it.
+ *
+ * **HOW THIS RECIPE WORKS** This recipe simulates a real-world scenario where "App A" deeplinks
+ * into "App B".
+ *
+ * "App A" is simulated by this current module [com.example.nav3recipes.deeplink.advanced], which
+ * contains the [AdvancedCreateDeepLinkActivity] that allows you to create a deeplink intent and
+ * trigger that in either the existing Task, or in a new Task.
+ *
+ * "App B" is simulated by the module [com.example.nav3recipes.deeplink.advanced], which contains
+ * the MainActivity that you deeplink into. That module shows you how to build a synthetic backStack
+ * and how to manage the Task stack properly in order to support both Back and Up buttons.
+ *
+ * See the [README](README.md) file of current module for more info on advanced deep linking.
+ */
+class AdvancedCreateDeepLinkActivity: ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ setEdgeToEdgeConfig()
+ super.onCreate(savedInstanceState)
+
+ setContent {
+ EntryScreen("Sandbox - Build Your Deeplink Intent") {
+ val initFirstName = MENU_OPTIONS_FIRST_NAME.values.first().first()
+ val initLocation = MENU_OPTIONS_LOCATION.values.last().first()
+ val initTaskStack = MENU_OPTIONS_TASK_STACK.values.first().first()
+ var firstName by remember { mutableStateOf(initFirstName) }
+ var location by remember { mutableStateOf(initLocation) }
+ var taskStack by remember { mutableStateOf(initTaskStack) }
+
+ // select first name
+ MenuDropDown(
+ menuOptions = MENU_OPTIONS_FIRST_NAME,
+ ) { _, selected ->
+ firstName = selected
+ }
+
+ // select first name
+ MenuDropDown(
+ menuOptions = MENU_OPTIONS_LOCATION,
+ ) { _, selected ->
+ location = selected
+ }
+
+ // select current task stack or build new task stack
+ MenuDropDown(
+ menuOptions = MENU_OPTIONS_TASK_STACK,
+ ) { _, selected ->
+ taskStack = selected
+ }
+
+ // build final deeplink URL and Intent
+ val finalUrl = "${ADVANCED_PATH_BASE}/user/$firstName/$location"
+
+ // display Intent info
+ val flagString = if (taskStack == TAG_NEW_TASK) {
+ "Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK"
+ } else ""
+ val intentString = """
+ | Final Intent:
+ | data = "$finalUrl"
+ | action = Intent.ACTION_VIEW
+ | flags = $flagString
+ """.trimMargin()
+
+ TextContent(intentString)
+
+ // deeplink to target
+ PaddedButton("Deeplink Away!", onClick = dropUnlessResumed {
+ val intent = Intent().apply {
+ data = finalUrl.toUri()
+ action = Intent.ACTION_VIEW
+ if (taskStack == TAG_NEW_TASK) {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ }
+ }
+
+ startActivity(intent)
+ })
+ }
+ }
+ }
+}
+
+private const val TAG_FIRST_NAME = "firstName"
+private const val TAG_LOCATION = "location"
+private const val TAG_TASK_STACK = "Task stack"
+private const val TAG_CURRENT_TASK = "Use Current Task Stack"
+private const val TAG_NEW_TASK = "Start New Task Stack"
+
+private val MENU_OPTIONS_FIRST_NAME = mapOf(
+ TAG_FIRST_NAME to LIST_FIRST_NAMES
+)
+
+private val MENU_OPTIONS_LOCATION = mapOf(
+ TAG_LOCATION to LIST_LOCATIONS
+)
+
+private val MENU_OPTIONS_TASK_STACK = mapOf(
+ TAG_TASK_STACK to listOf(TAG_CURRENT_TASK, TAG_NEW_TASK),
+)
+```
\ No newline at end of file
diff --git a/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-basic.md b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-basic.md
new file mode 100644
index 00000000..f5f1af27
--- /dev/null
+++ b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/deeplinks-basic.md
@@ -0,0 +1,744 @@
+# Deep Link Basic Recipe
+
+This recipe demonstrates how to parse a deep link URL from an Android Intent into a Navigation key.
+
+## How it works
+
+It consists of two activities - `CreateDeepLinkActivity` to construct and trigger the deeplink request, and the `MainActivity` to show how an app can handle that request.
+
+## Demonstrated forms of deeplink
+
+The `MainActivity` has several backStack keys to demonstrate different types of supported deeplinks:
+
+1. `HomeKey` - deeplink with an exact url (no deeplink arguments)
+2. `UsersKey` - deeplink with path arguments
+3. `SearchKey` - deeplink with query arguments
+
+See `MainActivity.deepLinkPatterns` for the actual url pattern of each.
+
+## Recipe structure
+
+This recipe consists of three main packages:
+
+1. `basic.deeplink` - Contains the two activities
+2. `basic.deeplink.ui` - Contains the activity UI code, i.e. global string variables, deeplink URLs etc
+3. `basic.deeplink.util` - Contains the classes and helper methods to parse and match the deeplinks
+
+[ Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/deeplink/basic)
+
+```
+package com.example.nav3recipes.deeplink.basic
+
+import androidx.navigation3.runtime.NavKey
+import com.example.nav3recipes.deeplink.basic.ui.STRING_LITERAL_FILTER
+import com.example.nav3recipes.deeplink.basic.ui.STRING_LITERAL_HOME
+import com.example.nav3recipes.deeplink.basic.ui.STRING_LITERAL_SEARCH
+import com.example.nav3recipes.deeplink.basic.ui.STRING_LITERAL_USERS
+import kotlinx.serialization.Serializable
+
+internal interface NavRecipeKey: NavKey {
+ val name: String
+}
+
+@Serializable
+internal object HomeKey: NavRecipeKey {
+ override val name: String = STRING_LITERAL_HOME
+}
+
+@Serializable
+internal data class UsersKey(
+ val filter: String,
+): NavRecipeKey {
+ override val name: String = STRING_LITERAL_USERS
+ companion object {
+ const val FILTER_KEY = STRING_LITERAL_FILTER
+ const val FILTER_OPTION_RECENTLY_ADDED = "recentlyAdded"
+ const val FILTER_OPTION_ALL = "all"
+ }
+}
+
+@Serializable
+internal data class SearchKey(
+ val firstName: String? = null,
+ val ageMin: Int? = null,
+ val ageMax: Int? = null,
+ val location: String? = null,
+): NavRecipeKey {
+ override val name: String = STRING_LITERAL_SEARCH
+}
+```
+
+```
+package com.example.nav3recipes.deeplink.basic
+
+import android.net.Uri
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.core.net.toUri
+import androidx.navigation3.runtime.NavBackStack
+import androidx.navigation3.runtime.NavKey
+import androidx.navigation3.runtime.entryProvider
+import androidx.navigation3.runtime.rememberNavBackStack
+import androidx.navigation3.ui.NavDisplay
+import com.example.nav3recipes.common.deeplink.EntryScreen
+import com.example.nav3recipes.common.deeplink.FriendsList
+import com.example.nav3recipes.common.deeplink.LIST_USERS
+import com.example.nav3recipes.common.deeplink.TextContent
+import com.example.nav3recipes.deeplink.basic.ui.URL_HOME_EXACT
+import com.example.nav3recipes.deeplink.basic.ui.URL_SEARCH
+import com.example.nav3recipes.deeplink.basic.ui.URL_USERS_WITH_FILTER
+import com.example.nav3recipes.deeplink.basic.util.DeepLinkMatchResult
+import com.example.nav3recipes.deeplink.basic.util.DeepLinkMatcher
+import com.example.nav3recipes.deeplink.basic.util.DeepLinkPattern
+import com.example.nav3recipes.deeplink.basic.util.DeepLinkRequest
+import com.example.nav3recipes.deeplink.basic.util.KeyDecoder
+import com.example.nav3recipes.ui.setEdgeToEdgeConfig
+
+/**
+ * Parses a target deeplink into a NavKey. There are several crucial steps involved:
+ *
+ * STEP 1.Parse supported deeplinks (URLs that can be deeplinked into) into a readily readable
+ * format (see [DeepLinkPattern])
+ * STEP 2. Parse the requested deeplink into a readily readable, format (see [DeepLinkRequest])
+ * **note** the parsed requested deeplink and parsed supported deeplinks should be cohesive with each
+ * other to facilitate comparison and finding a match
+ * STEP 3. Compare the requested deeplink target with supported deeplinks in order to find a match
+ * (see [DeepLinkMatchResult]). The match result's format should enable conversion from result
+ * to backstack key, regardless of what the conversion method may be.
+ * STEP 4. Associate the match results with the correct backstack key
+ *
+ * This recipes provides an example for each of the above steps by way of kotlinx.serialization.
+ *
+ * **This recipe is designed to focus on parsing an intent into a key, and therefore these additional
+ * deeplink considerations are not included in this scope**
+ * - Create synthetic backStack
+ * - Multi-modular setup
+ * - DI
+ * - Managing TaskStack
+ * - Up button ves Back Button
+ *
+ */
+class MainActivity : ComponentActivity() {
+ /** STEP 1. Parse supported deeplinks */
+ // internal so that landing activity can link to this in the kdocs
+ internal val deepLinkPatterns: List> = listOf(
+ // "https://www.nav3recipes.com/home"
+ DeepLinkPattern(HomeKey.serializer(), (URL_HOME_EXACT).toUri()),
+ // "https://www.nav3recipes.com/users/with/{filter}"
+ DeepLinkPattern(UsersKey.serializer(), (URL_USERS_WITH_FILTER).toUri()),
+ // "https://www.nav3recipes.com/users/search?{firstName}&{age}&{location}"
+ DeepLinkPattern(SearchKey.serializer(), (URL_SEARCH.toUri())),
+ )
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ setEdgeToEdgeConfig()
+ super.onCreate(savedInstanceState)
+
+ // retrieve the target Uri
+ val uri: Uri? = intent.data
+ // associate the target with the correct backstack key
+ val key: NavKey = uri?.let {
+ /** STEP 2. Parse requested deeplink */
+ val request = DeepLinkRequest(uri)
+ /** STEP 3. Compared requested with supported deeplink to find match*/
+ val match = deepLinkPatterns.firstNotNullOfOrNull { pattern ->
+ DeepLinkMatcher(request, pattern).match()
+ }
+ /** STEP 4. If match is found, associate match to the correct key*/
+ match?.let {
+ //leverage kotlinx.serialization's Decoder to decode
+ // match result into a backstack key
+ KeyDecoder(match.args)
+ .decodeSerializableValue(match.serializer)
+ }
+ } ?: HomeKey // fallback if intent.uri is null or match is not found
+
+ /**
+ * Then pass starting key to backstack
+ */
+ setContent {
+ val backStack: NavBackStack = rememberNavBackStack(key)
+ NavDisplay(
+ backStack = backStack,
+ onBack = { backStack.removeLastOrNull() },
+ entryProvider = entryProvider {
+ entry { key ->
+ EntryScreen(key.name) {
+ TextContent("")
+ }
+ }
+ entry { key ->
+ EntryScreen("${key.name} : ${key.filter}") {
+ TextContent("")
+ val list = when {
+ key.filter.isEmpty() -> LIST_USERS
+ key.filter == UsersKey.FILTER_OPTION_ALL -> LIST_USERS
+ else -> LIST_USERS.take(5)
+ }
+ FriendsList(list)
+ }
+ }
+ entry { search ->
+ EntryScreen(search.name) {
+ TextContent("")
+ val matchingUsers = LIST_USERS.filter { user ->
+ (search.firstName == null || user.firstName == search.firstName) &&
+ (search.location == null || user.location == search.location) &&
+ (search.ageMin == null || user.age >= search.ageMin) &&
+ (search.ageMax == null || user.age <= search.ageMax)
+ }
+ FriendsList(matchingUsers)
+ }
+ }
+ }
+ )
+ }
+ }
+}
+```
+
+```
+package com.example.nav3recipes.deeplink.basic
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.core.net.toUri
+import androidx.lifecycle.compose.dropUnlessResumed
+import com.example.nav3recipes.common.deeplink.EMPTY
+import com.example.nav3recipes.common.deeplink.EntryScreen
+import com.example.nav3recipes.common.deeplink.FIRST_NAME_JOHN
+import com.example.nav3recipes.common.deeplink.FIRST_NAME_JULIE
+import com.example.nav3recipes.common.deeplink.FIRST_NAME_MARY
+import com.example.nav3recipes.common.deeplink.FIRST_NAME_TOM
+import com.example.nav3recipes.common.deeplink.LOCATION_BC
+import com.example.nav3recipes.common.deeplink.LOCATION_BR
+import com.example.nav3recipes.common.deeplink.LOCATION_CA
+import com.example.nav3recipes.common.deeplink.LOCATION_US
+import com.example.nav3recipes.common.deeplink.MenuDropDown
+import com.example.nav3recipes.common.deeplink.MenuTextInput
+import com.example.nav3recipes.common.deeplink.PaddedButton
+import com.example.nav3recipes.common.deeplink.TextContent
+import com.example.nav3recipes.deeplink.basic.ui.PATH_BASE
+import com.example.nav3recipes.deeplink.basic.ui.PATH_INCLUDE
+import com.example.nav3recipes.deeplink.basic.ui.PATH_SEARCH
+import com.example.nav3recipes.deeplink.basic.ui.STRING_LITERAL_HOME
+import com.example.nav3recipes.ui.setEdgeToEdgeConfig
+
+/**
+ * This activity allows the user to create a deep link and make a request with it.
+ *
+ * **HOW THIS RECIPE WORKS** it consists of two activities - [CreateDeepLinkActivity] to construct
+ * and trigger the deeplink request, and the [MainActivity] to show how an app can handle
+ * that request.
+ *
+ * **DEMONSTRATED FORMS OF DEEPLINK** The [MainActivity] has a several backStack keys to
+ * demonstrate different types of supported deeplinks:
+ * 1. [HomeKey] - deeplink with an exact url (no deeplink arguments)
+ * 2. [UsersKey] - deeplink with path arguments
+ * 3. [SearchKey] - deeplink with query arguments
+ * See [MainActivity.deepLinkPatterns] for the actual url pattern of each.
+ *
+ * **RECIPE STRUCTURE** This recipe consists of three main packages:
+ * 1. basic.deeplink - Contains the two activities
+ * 2. basic.deeplink.ui - Contains the activity UI code, i.e. global string variables, deeplink URLs etc
+ * 3. basic.deeplink.util - Contains the classes and helper methods to parse and match
+ * the deeplinks
+ *
+ * See [MainActivity] for how the requested deeplink is handled.
+ */
+class CreateDeepLinkActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ setEdgeToEdgeConfig()
+ super.onCreate(savedInstanceState)
+
+ setContent {
+ /**
+ * UI for deeplink sandbox
+ */
+ EntryScreen("Sandbox - Build Your Deeplink") {
+ TextContent("Base url:\n${PATH_BASE}/")
+ var showFilterOptions by remember { mutableStateOf(false) }
+ val selectedPath = remember { mutableStateOf(MENU_OPTIONS_PATH[KEY_PATH]?.first()) }
+
+ var showQueryOptions by remember { mutableStateOf(false) }
+ var selectedFilter by remember { mutableStateOf("") }
+ val selectedSearchQuery = remember { mutableStateMapOf() }
+
+ // manage path options
+ MenuDropDown(
+ menuOptions = MENU_OPTIONS_PATH,
+ ) { _, selection ->
+ selectedPath.value = selection
+ when (selection) {
+ PATH_SEARCH -> {
+ showQueryOptions = true
+ showFilterOptions = false
+ }
+
+ PATH_INCLUDE -> {
+ showQueryOptions = false
+ showFilterOptions = true
+ }
+
+ else -> {
+ showQueryOptions = false
+ showFilterOptions = false
+ }
+ }
+ }
+
+ // manage path filter options, reset state if menu is closed
+ LaunchedEffect(showFilterOptions) {
+ selectedFilter = if (showFilterOptions) {
+ MENU_OPTIONS_FILTER.values.first().first()
+ } else {
+ ""
+ }
+ }
+ if (showFilterOptions) {
+ MenuDropDown(
+ menuOptions = MENU_OPTIONS_FILTER,
+ ) { _, selected ->
+ selectedFilter = selected
+ }
+ }
+
+ // manage query options, reset state if menu is closed
+ LaunchedEffect(showQueryOptions) {
+ if (showQueryOptions) {
+ val initEntry = MENU_OPTIONS_SEARCH.entries.first()
+ selectedSearchQuery[initEntry.key] = initEntry.value.first()
+ } else {
+ selectedSearchQuery.clear()
+ }
+ }
+ if (showQueryOptions) {
+ MenuTextInput(
+ menuLabels = MENU_LABELS_SEARCH,
+ ) { label, selected ->
+ selectedSearchQuery[label] = selected
+ }
+ MenuDropDown(
+ menuOptions = MENU_OPTIONS_SEARCH,
+ ) { label, selected ->
+ selectedSearchQuery[label] = selected
+ }
+ }
+
+ // form final deeplink url
+ val arguments = when (selectedPath.value) {
+ PATH_INCLUDE -> "/${selectedFilter}"
+ PATH_SEARCH -> {
+ buildString {
+ selectedSearchQuery.forEach { entry ->
+ if (entry.value.isNotEmpty()) {
+ val prefix = if (isEmpty()) "?" else "&"
+ append("$prefix${entry.key}=${entry.value}")
+ }
+ }
+ }
+ }
+
+ else -> ""
+ }
+ val finalUrl = "${PATH_BASE}/${selectedPath.value}$arguments"
+ TextContent("Final url:\n$finalUrl")
+ // deeplink to target
+ PaddedButton("Deeplink Away!", onClick = dropUnlessResumed {
+ val intent = Intent(
+ this@CreateDeepLinkActivity,
+ MainActivity::class.java
+ )
+ // start activity with the url
+ intent.data = finalUrl.toUri()
+ startActivity(intent)
+ })
+ }
+ }
+ }
+}
+
+private const val KEY_PATH = "path"
+private val MENU_OPTIONS_PATH = mapOf(
+ KEY_PATH to listOf(
+ STRING_LITERAL_HOME,
+ PATH_INCLUDE,
+ PATH_SEARCH,
+ ),
+)
+
+private val MENU_OPTIONS_FILTER = mapOf(
+ UsersKey.FILTER_KEY to listOf(UsersKey.FILTER_OPTION_RECENTLY_ADDED, UsersKey.FILTER_OPTION_ALL),
+)
+
+private val MENU_OPTIONS_SEARCH = mapOf(
+ SearchKey::firstName.name to listOf(
+ EMPTY,
+ FIRST_NAME_JOHN,
+ FIRST_NAME_TOM,
+ FIRST_NAME_MARY,
+ FIRST_NAME_JULIE
+ ),
+ SearchKey::location.name to listOf(EMPTY, LOCATION_CA, LOCATION_BC, LOCATION_BR, LOCATION_US)
+)
+
+private val MENU_LABELS_SEARCH = listOf(SearchKey::ageMin.name, SearchKey::ageMax.name)
+
+```
+
+```
+package com.example.nav3recipes.deeplink.basic.util
+
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.AbstractDecoder
+import kotlinx.serialization.encoding.CompositeDecoder
+import kotlinx.serialization.modules.EmptySerializersModule
+import kotlinx.serialization.modules.SerializersModule
+
+/**
+ * Decodes the list of arguments into a a back stack key
+ *
+ * **IMPORTANT** This decoder assumes that all argument types are Primitives.
+ */
+@OptIn(ExperimentalSerializationApi::class)
+internal class KeyDecoder(
+ private val arguments: Map,
+) : AbstractDecoder() {
+
+ override val serializersModule: SerializersModule = EmptySerializersModule()
+ private var elementIndex: Int = -1
+ private var elementName: String = ""
+
+ /**
+ * Decodes the index of the next element to be decoded. Index represents a position of the
+ * current element in the [descriptor] that can be found with [descriptor].getElementIndex.
+ *
+ * The returned index will trigger deserializer to call [decodeValue] on the argument at that
+ * index.
+ *
+ * The decoder continually calls this method to process the next available argument until this
+ * method returns [CompositeDecoder.DECODE_DONE], which indicates that there are no more
+ * arguments to decode.
+ *
+ * This method should sequentially return the element index for every element that has its value
+ * available within [arguments].
+ */
+ override fun decodeElementIndex(descriptor: SerialDescriptor): Int {
+ var currentIndex = elementIndex
+ while (true) {
+ // proceed to next element
+ currentIndex++
+ // if we have reached the end, let decoder know there are not more arguments to decode
+ if (currentIndex >= descriptor.elementsCount) return CompositeDecoder.DECODE_DONE
+ val currentName = descriptor.getElementName(currentIndex)
+ // Check if bundle has argument value. If so, we tell decoder to process
+ // currentIndex. Otherwise, we skip this index and proceed to next index.
+ if (arguments.contains(currentName)) {
+ elementIndex = currentIndex
+ elementName = currentName
+ return elementIndex
+ }
+ }
+ }
+
+ /**
+ * Returns argument value from the [arguments] for the argument at the index returned by
+ * [decodeElementIndex]
+ */
+ override fun decodeValue(): Any {
+ val arg = arguments[elementName]
+ checkNotNull(arg) { "Unexpected null value for non-nullable argument $elementName" }
+ return arg
+ }
+
+ override fun decodeNull(): Nothing? = null
+
+ // we want to know if it is not null, so its !isNull
+ override fun decodeNotNullMark(): Boolean = arguments[elementName] != null
+}
+```
+
+```
+package com.example.nav3recipes.deeplink.basic.util
+
+import android.net.Uri
+
+/**
+ * Parse the requested Uri and store it in a easily readable format
+ *
+ * @param uri the target deeplink uri to link to
+ */
+internal class DeepLinkRequest(
+ val uri: Uri
+) {
+ /**
+ * A list of path segments
+ */
+ val pathSegments: List = uri.pathSegments
+
+ /**
+ * A map of query name to query value
+ */
+ val queries = buildMap {
+ uri.queryParameterNames.forEach { argName ->
+ this[argName] = uri.getQueryParameter(argName)!!
+ }
+ }
+
+ // TODO add parsing for other Uri components, i.e. fragments, mimeType, action
+}
+```
+
+````
+package com.example.nav3recipes.deeplink.basic.util
+
+import android.net.Uri
+import androidx.navigation3.runtime.NavKey
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.SerialKind
+import kotlinx.serialization.encoding.CompositeDecoder
+import java.io.Serializable
+
+/**
+ * Parse a supported deeplink and stores its metadata as a easily readable format
+ *
+ * The following notes applies specifically to this particular sample implementation:
+ *
+ * The supported deeplink is expected to be built from a serializable backstack key [T] that
+ * supports deeplink. This means that if this deeplink contains any arguments (path or query),
+ * the argument name must match any of [T] member field name.
+ *
+ * One [DeepLinkPattern] should be created for each supported deeplink. This means if [T]
+ * supports two deeplink patterns:
+ * ```
+ * val deeplink1 = www.nav3recipes.com/home
+ * val deeplink2 = www.nav3recipes.com/profile/{userId}
+ * ```
+ * Then two [DeepLinkPattern] should be created
+ * ```
+ * val parsedDeeplink1 = DeepLinkPattern(T.serializer(), deeplink1)
+ * val parsedDeeplink2 = DeepLinkPattern(T.serializer(), deeplink2)
+ * ```
+ *
+ * This implementation assumes a few things:
+ * 1. all path arguments are required/non-nullable - partial path matches will be considered a non-match
+ * 2. all query arguments are optional by way of nullable/has default value
+ *
+ * @param T the backstack key type that supports the deeplinking of [uriPattern]
+ * @param serializer the serializer of [T]
+ * @param uriPattern the supported deeplink's uri pattern, i.e. "abc.com/home/{pathArg}"
+ */
+internal class DeepLinkPattern(
+ val serializer: KSerializer,
+ val uriPattern: Uri
+) {
+ /**
+ * Help differentiate if a path segment is an argument or a static value
+ */
+ private val regexPatternFillIn = Regex("\\{(.+?)\\}")
+
+ // TODO make these lazy
+ /**
+ * parse the path into a list of [PathSegment]
+ *
+ * order matters here - path segments need to match in value and order when matching
+ * requested deeplink to supported deeplink
+ */
+ val pathSegments: List = buildList {
+ uriPattern.pathSegments.forEach { segment ->
+ // first, check if it is a path arg
+ var result = regexPatternFillIn.find(segment)
+ if (result != null) {
+ // if so, extract the path arg name (the string value within the curly braces)
+ val argName = result.groups[1]!!.value
+ // from [T], read the primitive type of this argument to get the correct type parser
+ val elementIndex = serializer.descriptor.getElementIndex(argName)
+ if (elementIndex == CompositeDecoder.UNKNOWN_NAME) {
+ throw IllegalArgumentException(
+ "Path parameter '{$argName}' defined in the DeepLink $uriPattern does not exist in the Serializable class '${serializer.descriptor.serialName}'."
+ )
+ }
+
+ val elementDescriptor = serializer.descriptor.getElementDescriptor(elementIndex)
+ // finally, add the arg name and its respective type parser to the map
+ add(PathSegment(argName, true, getTypeParser(elementDescriptor.kind)))
+ } else {
+ // if its not a path arg, then its just a static string path segment
+ add(PathSegment(segment, false, getTypeParser(PrimitiveKind.STRING)))
+ }
+ }
+ }
+
+ /**
+ * Parse supported queries into a map of queryParameterNames to [TypeParser]
+ *
+ * This will be used later on to parse a provided query value into the correct KType
+ */
+ val queryValueParsers: Map = buildMap {
+ uriPattern.queryParameterNames.forEach { paramName ->
+ val elementIndex = serializer.descriptor.getElementIndex(paramName)
+ // Ignore static query parameters that are not in the Serializable class
+ if (elementIndex != CompositeDecoder.UNKNOWN_NAME) {
+ val elementDescriptor = serializer.descriptor.getElementDescriptor(elementIndex)
+ this[paramName] = getTypeParser(elementDescriptor.kind)
+ }
+ }
+ }
+
+ /**
+ * Metadata about a supported path segment
+ */
+ class PathSegment(
+ val stringValue: String,
+ val isParamArg: Boolean,
+ val typeParser: TypeParser
+ )
+}
+
+/**
+ * Parses a String into a Serializable Primitive
+ */
+private typealias TypeParser = (String) -> Serializable
+
+private fun getTypeParser(kind: SerialKind): TypeParser {
+ return when (kind) {
+ PrimitiveKind.STRING -> Any::toString
+ PrimitiveKind.INT -> String::toInt
+ PrimitiveKind.BOOLEAN -> String::toBoolean
+ PrimitiveKind.BYTE -> String::toByte
+ PrimitiveKind.CHAR -> String::toCharArray
+ PrimitiveKind.DOUBLE -> String::toDouble
+ PrimitiveKind.FLOAT -> String::toFloat
+ PrimitiveKind.LONG -> String::toLong
+ PrimitiveKind.SHORT -> String::toShort
+ else -> throw IllegalArgumentException(
+ "Unsupported argument type of SerialKind:$kind. The argument type must be a Primitive."
+ )
+ }
+}
+````
+
+```
+package com.example.nav3recipes.deeplink.basic.util
+
+import android.util.Log
+import androidx.navigation3.runtime.NavKey
+import kotlinx.serialization.KSerializer
+
+internal class DeepLinkMatcher(
+ val request: DeepLinkRequest,
+ val deepLinkPattern: DeepLinkPattern
+) {
+ /**
+ * Match a [DeepLinkRequest] to a [DeepLinkPattern].
+ *
+ * Returns a [DeepLinkMatchResult] if this matches the pattern, returns null otherwise
+ */
+ fun match(): DeepLinkMatchResult? {
+ if (request.uri.scheme != deepLinkPattern.uriPattern.scheme) return null
+ if (!request.uri.authority.equals(deepLinkPattern.uriPattern.authority, ignoreCase = true)) return null
+ if (request.pathSegments.size != deepLinkPattern.pathSegments.size) return null
+ // exact match (url does not contain any arguments)
+ if (request.uri == deepLinkPattern.uriPattern)
+ return DeepLinkMatchResult(deepLinkPattern.serializer, mapOf())
+
+ val args = mutableMapOf()
+ // match the path
+ request.pathSegments
+ .asSequence()
+ // zip to compare the two objects side by side, order matters here so we
+ // need to make sure the compared segments are at the same position within the url
+ .zip(deepLinkPattern.pathSegments.asSequence())
+ .forEach { it ->
+ // retrieve the two path segments to compare
+ val requestedSegment = it.first
+ val candidateSegment = it.second
+ // if the potential match expects a path arg for this segment, try to parse the
+ // requested segment into the expected type
+ if (candidateSegment.isParamArg) {
+ val parsedValue = try {
+ candidateSegment.typeParser.invoke(requestedSegment)
+ } catch (e: IllegalArgumentException) {
+ Log.e(TAG_LOG_ERROR, "Failed to parse path value:[$requestedSegment].", e)
+ return null
+ }
+ args[candidateSegment.stringValue] = parsedValue
+ } else if(requestedSegment != candidateSegment.stringValue){
+ // if it's path arg is not the expected type, its not a match
+ return null
+ }
+ }
+ // match queries (if any)
+ request.queries.forEach { query ->
+ val name = query.key
+ // If the pattern does not define this query parameter, ignore it.
+ // This prevents a NullPointerException.
+ val queryStringParser = deepLinkPattern.queryValueParsers[name]?: return@forEach
+
+ val queryParsedValue = try {
+ queryStringParser.invoke(query.value)
+ } catch (e: IllegalArgumentException) {
+ Log.e(TAG_LOG_ERROR, "Failed to parse query name:[$name] value:[${query.value}].", e)
+ return null
+ }
+ args[name] = queryParsedValue
+ }
+ // provide the serializer of the matching key and map of arg names to parsed arg values
+ return DeepLinkMatchResult(deepLinkPattern.serializer, args)
+ }
+}
+
+
+/**
+ * Created when a requested deeplink matches with a supported deeplink
+ *
+ * @param [T] the backstack key associated with the deeplink that matched with the requested deeplink
+ * @param serializer serializer for [T]
+ * @param args The map of argument name to argument value. The value is expected to have already
+ * been parsed from the raw url string back into its proper KType as declared in [T].
+ * Includes arguments for all parts of the uri - path, query, etc.
+ * */
+internal data class DeepLinkMatchResult(
+ val serializer: KSerializer,
+ val args: Map
+)
+
+const val TAG_LOG_ERROR = "Nav3RecipesDeepLink"
+```
+
+```
+package com.example.nav3recipes.deeplink.basic.ui
+
+import com.example.nav3recipes.deeplink.basic.SearchKey
+
+/**
+ * String resources
+ */
+internal const val STRING_LITERAL_FILTER = "filter"
+internal const val STRING_LITERAL_HOME = "home"
+internal const val STRING_LITERAL_USERS = "users"
+internal const val STRING_LITERAL_SEARCH = "search"
+internal const val STRING_LITERAL_INCLUDE = "include"
+internal const val PATH_BASE = "https://www.nav3recipes.com"
+internal const val PATH_INCLUDE = "$STRING_LITERAL_USERS/$STRING_LITERAL_INCLUDE"
+internal const val PATH_SEARCH = "$STRING_LITERAL_USERS/$STRING_LITERAL_SEARCH"
+internal const val URL_HOME_EXACT = "$PATH_BASE/$STRING_LITERAL_HOME"
+
+internal const val URL_USERS_WITH_FILTER = "$PATH_BASE/$PATH_INCLUDE/{$STRING_LITERAL_FILTER}"
+internal val URL_SEARCH = "$PATH_BASE/$PATH_SEARCH" +
+ "?${SearchKey::ageMin.name}={${SearchKey::ageMin.name}}" +
+ "&${SearchKey::ageMax.name}={${SearchKey::ageMax.name}}" +
+ "&${SearchKey::firstName.name}={${SearchKey::firstName.name}}" +
+ "&${SearchKey::location.name}={${SearchKey::location.name}}"
+```
diff --git a/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/dialog.md b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/dialog.md
new file mode 100644
index 00000000..6ef8efe5
--- /dev/null
+++ b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/dialog.md
@@ -0,0 +1,107 @@
+# Dialog Recipe
+
+This recipe demonstrates how to display a destination as a dialog.
+
+## How it works
+
+To show a destination as a dialog, you need to do two things:
+
+1. **Use `DialogSceneStrategy`** : Create an instance of `DialogSceneStrategy` and pass it to the `sceneStrategy` parameter of the `NavDisplay` composable.
+
+2. **Add metadata to the destination** : For the destination that you want to display as a dialog, add `DialogSceneStrategy.dialog()` to its metadata. This is done in the `entry` function. You can also pass a `DialogProperties` object to customize the dialog's behavior and appearance.
+
+In this example, `RouteB` is configured to be a dialog. When you navigate from `RouteA` to `RouteB`, `RouteB` will be displayed in a dialog window.
+
+The content of the dialog can be styled as needed. In this recipe, the content is clipped to have rounded corners.
+
+For more information, see the official documentation on [custom layouts](https://developer.android.com/guide/navigation/navigation-3/custom-layouts).
+[ Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/dialog)
+
+```
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.nav3recipes.dialog
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.DialogProperties
+import androidx.lifecycle.compose.dropUnlessResumed
+import androidx.navigation3.runtime.NavKey
+import androidx.navigation3.runtime.entryProvider
+import androidx.navigation3.runtime.rememberNavBackStack
+import androidx.navigation3.scene.DialogSceneStrategy
+import androidx.navigation3.ui.NavDisplay
+import com.example.nav3recipes.content.ContentBlue
+import com.example.nav3recipes.content.ContentGreen
+import com.example.nav3recipes.ui.setEdgeToEdgeConfig
+import kotlinx.serialization.Serializable
+
+@Serializable
+private data object RouteA : NavKey
+
+@Serializable
+private data class RouteB(val id: String) : NavKey
+
+class DialogActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ setEdgeToEdgeConfig()
+ super.onCreate(savedInstanceState)
+ setContent {
+ val backStack = rememberNavBackStack(RouteA)
+ val dialogStrategy = remember { DialogSceneStrategy() }
+
+ NavDisplay(
+ backStack = backStack,
+ onBack = { backStack.removeLastOrNull() },
+ sceneStrategies = listOf(dialogStrategy),
+ entryProvider = entryProvider {
+ entry {
+ ContentGreen("Welcome to Nav3") {
+ Button(onClick = dropUnlessResumed {
+ backStack.add(RouteB("123"))
+ }) {
+ Text("Click to open dialog")
+ }
+ }
+ }
+ entry(
+ metadata = DialogSceneStrategy.dialog(
+ DialogProperties(windowTitle = "Route B dialog")
+ )
+ ) { key ->
+ ContentBlue(
+ title = "Route id: ${key.id}",
+ modifier = Modifier.clip(
+ shape = RoundedCornerShape(16.dp)
+ )
+ )
+ }
+ }
+ )
+ }
+ }
+}
+```
\ No newline at end of file
diff --git a/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/material-listdetail.md b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/material-listdetail.md
new file mode 100644
index 00000000..fbaae791
--- /dev/null
+++ b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/material-listdetail.md
@@ -0,0 +1,141 @@
+# Material List-Detail Recipe
+
+This recipe demonstrates how to create an adaptive list-detail layout using the `ListDetailSceneStrategy` from the Material 3 Adaptive library. This layout automatically adjusts to show one, two, or three panes depending on the available screen width.
+
+## How it works
+
+This example has three destinations: `ConversationList`, `ConversationDetail`, and `Profile`.
+
+### `ListDetailSceneStrategy`
+
+The key to this recipe is the `rememberListDetailSceneStrategy`, which provides the logic for the adaptive layout.
+
+- **Pane Roles**: Each destination is assigned a role using metadata:
+
+ - `ListDetailSceneStrategy.listPane()`: For the primary (list) content. This pane is always visible. A placeholder can be provided to be shown in the detail pane area when no detail content is selected.
+ - `ListDetailSceneStrategy.detailPane()`: For the secondary (detail) content.
+ - `ListDetailSceneStrategy.extraPane()`: For tertiary content.
+- **Adaptive Layout** : The `ListDetailSceneStrategy` automatically handles the layout. On smaller screens, only one pane is shown at a time. On wider screens, it will show the list and detail panes side-by-side. On very wide screens, it can show all three panes: list, detail, and extra.
+
+- **Navigation** : Navigation between the panes is handled by adding and removing destinations from the back stack as usual. The `ListDetailSceneStrategy` observes the back stack and adjusts the layout accordingly.
+
+[ Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/material/listdetail)
+
+```
+package com.example.nav3recipes.material.listdetail
+
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
+import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
+import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
+import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy
+import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.dropUnlessResumed
+import androidx.navigation3.runtime.NavKey
+import androidx.navigation3.runtime.entryProvider
+import androidx.navigation3.runtime.rememberNavBackStack
+import androidx.navigation3.ui.NavDisplay
+import com.example.nav3recipes.content.ContentBlue
+import com.example.nav3recipes.content.ContentGreen
+import com.example.nav3recipes.content.ContentRed
+import com.example.nav3recipes.content.ContentYellow
+import com.example.nav3recipes.ui.setEdgeToEdgeConfig
+import kotlinx.serialization.Serializable
+
+@Serializable
+private object ConversationList : NavKey
+
+@Serializable
+private data class ConversationDetail(val id: String) : NavKey
+
+@Serializable
+private data object Profile : NavKey
+
+class MaterialListDetailActivity : ComponentActivity() {
+
+ @OptIn(ExperimentalMaterial3AdaptiveApi::class)
+ override fun onCreate(savedInstanceState: Bundle?) {
+ setEdgeToEdgeConfig()
+ super.onCreate(savedInstanceState)
+
+ setContent {
+
+ val backStack = rememberNavBackStack(ConversationList)
+
+ // Override the defaults so that there isn't a horizontal space between the panes.
+ // See b/418201867
+ val windowAdaptiveInfo = currentWindowAdaptiveInfoV2()
+ val directive = remember(windowAdaptiveInfo) {
+ calculatePaneScaffoldDirective(windowAdaptiveInfo)
+ .copy(horizontalPartitionSpacerSize = 0.dp)
+ }
+ val listDetailStrategy = rememberListDetailSceneStrategy(directive = directive)
+
+ NavDisplay(
+ backStack = backStack,
+ onBack = { backStack.removeLastOrNull() },
+ sceneStrategies = listOf(listDetailStrategy),
+ entryProvider = entryProvider {
+ entry(
+ metadata = ListDetailSceneStrategy.listPane(
+ detailPlaceholder = {
+ ContentYellow("Choose a conversation from the list")
+ }
+ )
+ ) {
+ ContentRed("Welcome to Nav3") {
+ Button(onClick = dropUnlessResumed {
+ backStack.add(ConversationDetail("ABC"))
+ }) {
+ Text("View conversation")
+ }
+ }
+ }
+ entry(
+ metadata = ListDetailSceneStrategy.detailPane()
+ ) { conversation ->
+ ContentBlue("Conversation ${conversation.id} ") {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Button(onClick = dropUnlessResumed {
+ backStack.add(Profile)
+ }) {
+ Text("View profile")
+ }
+ }
+ }
+ }
+ entry(
+ metadata = ListDetailSceneStrategy.extraPane()
+ ) {
+ ContentGreen("Profile")
+ }
+ }
+ )
+ }
+ }
+}
+```
\ No newline at end of file
diff --git a/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/material-supportingpane.md b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/material-supportingpane.md
new file mode 100644
index 00000000..58a6a7cc
--- /dev/null
+++ b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/material-supportingpane.md
@@ -0,0 +1,145 @@
+# Material Supporting Pane Recipe
+
+This recipe demonstrates how to create an adaptive layout with a main pane and a supporting pane using the `SupportingPaneSceneStrategy` from the Material 3 Adaptive library. This layout is useful for displaying supplementary content alongside the main content on larger screens.
+
+## How it works
+
+This example has three destinations: `MainVideo`, `RelatedVideos`, and `Profile`.
+
+### `SupportingPaneSceneStrategy`
+
+The `rememberSupportingPaneSceneStrategy` provides the logic for this adaptive layout.
+
+- **Pane Roles**: Each destination is assigned a role using metadata:
+
+ - `SupportingPaneSceneStrategy.mainPane()`: For the primary content. This pane is always visible.
+ - `SupportingPaneSceneStrategy.supportingPane()`: For the supplementary content. This pane is shown alongside the main pane on larger screens.
+ - `SupportingPaneSceneStrategy.extraPane()`: For tertiary content that can be displayed alongside the supporting pane on even larger screens.
+- **Adaptive Layout** : The `SupportingPaneSceneStrategy` automatically handles the layout. On smaller screens, only the main pane is shown. On larger screens, the supporting pane is shown next to the main pane.
+
+- **Back Navigation** : The `BackNavigationBehavior` is customized in this example to `PopUntilCurrentDestinationChange`. This means that when the user presses the back button, the supporting pane will be dismissed, revealing the main pane underneath.
+
+- **Navigation** : Navigation is handled by adding and removing destinations from the back stack. The `SupportingPaneSceneStrategy` observes these changes and adjusts the layout accordingly.
+
+[ Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/material/supportingpane)
+
+```
+package com.example.nav3recipes.material.supportingpane
+
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
+import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
+import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
+import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior
+import androidx.compose.material3.adaptive.navigation3.SupportingPaneSceneStrategy
+import androidx.compose.material3.adaptive.navigation3.rememberSupportingPaneSceneStrategy
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.dropUnlessResumed
+import androidx.navigation3.runtime.NavKey
+import androidx.navigation3.runtime.entryProvider
+import androidx.navigation3.runtime.rememberNavBackStack
+import androidx.navigation3.ui.NavDisplay
+import com.example.nav3recipes.content.ContentBlue
+import com.example.nav3recipes.content.ContentGreen
+import com.example.nav3recipes.content.ContentRed
+import com.example.nav3recipes.ui.setEdgeToEdgeConfig
+import kotlinx.serialization.Serializable
+
+@Serializable
+private object MainVideo : NavKey
+
+@Serializable
+private data object RelatedVideos : NavKey
+
+@Serializable
+private data object Profile : NavKey
+
+class MaterialSupportingPaneActivity : ComponentActivity() {
+
+ @OptIn(ExperimentalMaterial3AdaptiveApi::class)
+ override fun onCreate(savedInstanceState: Bundle?) {
+ setEdgeToEdgeConfig()
+ super.onCreate(savedInstanceState)
+
+ setContent {
+
+ val backStack = rememberNavBackStack(MainVideo)
+
+ // Override the defaults so that there isn't a horizontal or vertical space between the panes.
+ // See b/444438086
+ val windowAdaptiveInfo = currentWindowAdaptiveInfoV2()
+ val directive = remember(windowAdaptiveInfo) {
+ calculatePaneScaffoldDirective(windowAdaptiveInfo)
+ .copy(horizontalPartitionSpacerSize = 0.dp, verticalPartitionSpacerSize = 0.dp)
+ }
+
+ // Override the defaults so that the supporting pane can be dismissed by pressing back.
+ // See b/445826749
+ val supportingPaneStrategy = rememberSupportingPaneSceneStrategy(
+ backNavigationBehavior = BackNavigationBehavior.PopUntilCurrentDestinationChange,
+ directive = directive
+ )
+
+ NavDisplay(
+ backStack = backStack,
+ onBack = { backStack.removeLastOrNull() },
+ sceneStrategies = listOf(supportingPaneStrategy),
+ entryProvider = entryProvider {
+ entry(
+ metadata = SupportingPaneSceneStrategy.mainPane()
+ ) {
+ ContentRed("Video content") {
+ Button(onClick = dropUnlessResumed {
+ backStack.add(RelatedVideos)
+ }) {
+ Text("View related videos")
+ }
+ }
+ }
+ entry(
+ metadata = SupportingPaneSceneStrategy.supportingPane()
+ ) {
+ ContentBlue("Related videos") {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Button(onClick = dropUnlessResumed {
+ backStack.add(Profile)
+ }) {
+ Text("View profile")
+ }
+ }
+ }
+ }
+ entry(
+ metadata = SupportingPaneSceneStrategy.extraPane()
+ ) {
+ ContentGreen("Profile")
+ }
+ }
+ )
+ }
+ }
+}
+```
\ No newline at end of file
diff --git a/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/modular-hilt.md b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/modular-hilt.md
new file mode 100644
index 00000000..fcc94a24
--- /dev/null
+++ b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/modular-hilt.md
@@ -0,0 +1,283 @@
+# Modular Navigation Recipe (Hilt)
+
+This recipe demonstrates how to structure a multi-module application using Navigation 3 and Dagger/Hilt for dependency injection. The goal is to create a decoupled architecture where navigation is defined and implemented in separate feature modules.
+
+## How it works
+
+The application is divided into several modules:
+
+- **`app` module** : This is the main application module. It initializes a common `Navigator` and injects a set of `EntryProviderInstaller`s from the feature modules. It then uses these installers to build the final `entryProvider` for the `NavDisplay`.
+
+- **`common` module**: This module contains the core navigation logic, including:
+
+ - A `Navigator` class that manages the back stack.
+ - An `EntryProviderInstaller` type, which is a function that feature modules use to contribute their navigation entries to the application's `entryProvider`.
+- **Feature modules (e.g., `conversation`, `profile`)**: Each feature is split into two sub-modules:
+
+ - **`api` module**: Defines the public API for the feature, including its navigation routes. This allows other modules to navigate to this feature without needing to know about its implementation details.
+ - **`impl` module** : Provides the implementation of the feature, including its composables and an `EntryProviderInstaller` that maps the feature's routes to its composables. This installer is then provided to the `app` module using Dagger/Hilt.
+
+This modular approach allows for a clean separation of concerns, making the codebase more scalable and maintainable. Each feature is responsible for its own navigation logic, and the `app` module only combines these pieces together.
+[ Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/modular/hilt)
+
+```
+package com.example.nav3recipes.modular.hilt
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ActivityRetainedComponent
+import dagger.multibindings.IntoSet
+
+// API
+object Profile
+
+// IMPLEMENTATION
+@Module
+@InstallIn(ActivityRetainedComponent::class)
+object ProfileModule {
+
+ @IntoSet
+ @Provides
+ fun provideEntryProviderInstaller() : EntryProviderInstaller = {
+ entry{
+ ProfileScreen()
+ }
+ }
+}
+
+@Composable
+private fun ProfileScreen() {
+ val profileColor = MaterialTheme.colorScheme.surfaceVariant
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(profileColor)
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = "Profile Screen",
+ style = MaterialTheme.typography.headlineMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+}
+```
+
+```
+package com.example.nav3recipes.modular.hilt
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Scaffold
+import androidx.compose.ui.Modifier
+import androidx.navigation3.runtime.entryProvider
+import androidx.navigation3.ui.NavDisplay
+import com.example.nav3recipes.ui.setEdgeToEdgeConfig
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class HiltModularActivity : ComponentActivity() {
+
+ @Inject
+ lateinit var navigator: Navigator
+
+ @Inject
+ lateinit var entryProviderScopes: Set<@JvmSuppressWildcards EntryProviderInstaller>
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setEdgeToEdgeConfig()
+ setContent {
+ Scaffold { paddingValues ->
+ NavDisplay(
+ backStack = navigator.backStack,
+ modifier = Modifier.padding(paddingValues),
+ onBack = { navigator.goBack() },
+ entryProvider = entryProvider {
+ entryProviderScopes.forEach { builder -> this.builder() }
+ }
+ )
+ }
+ }
+ }
+}
+```
+
+```
+package com.example.nav3recipes.modular.hilt
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.Button
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.dropUnlessResumed
+import com.example.nav3recipes.ui.theme.colors
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ActivityRetainedComponent
+import dagger.multibindings.IntoSet
+
+// API
+object ConversationList
+data class ConversationDetail(val id: Int) {
+ val color: Color
+ get() = colors[id % colors.size]
+}
+
+// IMPL
+@Module
+@InstallIn(ActivityRetainedComponent::class)
+object ConversationModule {
+
+ @IntoSet
+ @Provides
+ fun provideEntryProviderInstaller(navigator: Navigator): EntryProviderInstaller =
+ {
+ entry {
+ ConversationListScreen(
+ onConversationClicked = { conversationDetail ->
+ navigator.goTo(conversationDetail)
+ }
+ )
+ }
+ entry { key ->
+ ConversationDetailScreen(key) { navigator.goTo(Profile) }
+ }
+ }
+}
+
+@Composable
+private fun ConversationListScreen(
+ onConversationClicked: (ConversationDetail) -> Unit
+) {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ items(10) { index ->
+ val conversationId = index + 1
+ val conversationDetail = ConversationDetail(conversationId)
+ val backgroundColor = conversationDetail.color
+ ListItem(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = dropUnlessResumed {
+ onConversationClicked(conversationDetail)
+ }),
+ headlineContent = {
+ Text(
+ text = "Conversation $conversationId",
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ },
+ colors = ListItemDefaults.colors(
+ containerColor = backgroundColor // Set container color directly
+ )
+ )
+ }
+ }
+}
+
+@Composable
+private fun ConversationDetailScreen(
+ conversationDetail: ConversationDetail,
+ onProfileClicked: () -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(conversationDetail.color)
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = "Conversation Detail Screen: ${conversationDetail.id}",
+ style = MaterialTheme.typography.headlineMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Button(onClick = dropUnlessResumed(block = onProfileClicked)) {
+ Text("View Profile")
+ }
+ }
+}
+```
+
+```
+package com.example.nav3recipes.modular.hilt
+
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.snapshots.SnapshotStateList
+import androidx.navigation3.runtime.EntryProviderScope
+import dagger.hilt.android.scopes.ActivityRetainedScoped
+
+
+typealias EntryProviderInstaller = EntryProviderScope.() -> Unit
+
+@ActivityRetainedScoped
+class Navigator(startDestination: Any) {
+ val backStack : SnapshotStateList = mutableStateListOf(startDestination)
+
+ fun goTo(destination: Any){
+ backStack.add(destination)
+ }
+
+ fun goBack(){
+ backStack.removeLastOrNull()
+ }
+}
+```
+
+```
+package com.example.nav3recipes.modular.hilt
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ActivityRetainedComponent
+import dagger.hilt.android.scopes.ActivityRetainedScoped
+
+@Module
+@InstallIn(ActivityRetainedComponent::class)
+object AppModule {
+
+ @Provides
+ @ActivityRetainedScoped
+ fun provideNavigator() : Navigator = Navigator(startDestination = ConversationList)
+}
+```
\ No newline at end of file
diff --git a/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/modular-koin.md b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/modular-koin.md
new file mode 100644
index 00000000..5ef2e3af
--- /dev/null
+++ b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/modular-koin.md
@@ -0,0 +1,287 @@
+# Modular Navigation Recipe (Koin)
+
+This recipe demonstrates how to structure a multi-module application using Navigation 3 and Koin for dependency injection. The goal is to create a decoupled architecture where navigation is defined and implemented in separate feature modules. It relies on the [`koin-compose-navigation3`](https://insert-koin.io/docs/reference/koin-compose/navigation3) artifact.
+
+## How it works
+
+The application is divided into several Android modules:
+
+- **`app` module** : This is the main application module. It `includes()` the feature modules and initializes a common `Navigator`.
+
+- **`common` module** : This module contains the core navigation logic used by both the application module and the feature modules. Namely, it defines a `Navigator` class that manages the back stack.
+
+- **Feature modules (e.g., `conversation`, `profile`)**: Each feature is split into two sub-modules:
+
+ - **`api` module**: Defines the public API for the feature, including its navigation routes. This allows other modules to navigate to this feature without needing to know about its implementation details.
+ - **`impl` module** : Provides the implementation of the feature, including its composables and Koin `Module`. The Koin module uses the [`navigation`](https://insert-koin.io/docs/reference/koin-compose/navigation3/#declaring-navigation-entries) DSL to define the entry provider installers for the feature module.
+
+This modular approach allows for a clean separation of concerns, making the codebase more scalable and maintainable. Each feature is responsible for its own navigation logic, and the `app` module only combines these pieces together.
+[ Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/modular/koin)
+
+```
+package com.example.nav3recipes.modular.koin
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import org.koin.androidx.scope.dsl.activityRetainedScope
+import org.koin.core.annotation.KoinExperimentalAPI
+import org.koin.dsl.module
+import org.koin.dsl.navigation3.navigation
+
+// API
+object Profile
+
+// IMPL
+@OptIn(KoinExperimentalAPI::class)
+val profileModule = module {
+ activityRetainedScope {
+ navigation { ProfileScreen() }
+ }
+}
+
+@Composable
+private fun ProfileScreen() {
+ val profileColor = MaterialTheme.colorScheme.surfaceVariant
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(profileColor)
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = "Profile Screen",
+ style = MaterialTheme.typography.headlineMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+}
+```
+
+```
+package com.example.nav3recipes.modular.koin
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.Button
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.dropUnlessResumed
+import com.example.nav3recipes.ui.theme.colors
+import org.koin.androidx.scope.dsl.activityRetainedScope
+import org.koin.core.annotation.KoinExperimentalAPI
+import org.koin.dsl.module
+import org.koin.dsl.navigation3.navigation
+
+// API
+object ConversationList
+data class ConversationDetail(val id: Int) {
+ val color: Color
+ get() = colors[id % colors.size]
+}
+
+// IMPL
+@OptIn(KoinExperimentalAPI::class)
+val conversationModule = module {
+ activityRetainedScope {
+ navigation {
+ ConversationListScreen(
+ onConversationClicked = { conversationDetail ->
+ get().goTo(conversationDetail)
+ }
+ )
+ }
+
+ navigation { key ->
+ ConversationDetailScreen(key) {
+ get().goTo(Profile)
+ }
+ }
+ }
+}
+
+@Composable
+private fun ConversationListScreen(
+ onConversationClicked: (ConversationDetail) -> Unit
+) {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ items(10) { index ->
+ val conversationId = index + 1
+ val conversationDetail = ConversationDetail(conversationId)
+ val backgroundColor = conversationDetail.color
+ ListItem(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = dropUnlessResumed {
+ onConversationClicked(conversationDetail)
+ }),
+ headlineContent = {
+ Text(
+ text = "Conversation $conversationId",
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ },
+ colors = ListItemDefaults.colors(
+ containerColor = backgroundColor // Set container color directly
+ )
+ )
+ }
+ }
+}
+
+@Composable
+private fun ConversationDetailScreen(
+ conversationDetail: ConversationDetail,
+ onProfileClicked: () -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(conversationDetail.color)
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = "Conversation Detail Screen: ${conversationDetail.id}",
+ style = MaterialTheme.typography.headlineMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Button(onClick = dropUnlessResumed(block = onProfileClicked)) {
+ Text("View Profile")
+ }
+ }
+}
+```
+
+```
+package com.example.nav3recipes.modular.koin
+
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.snapshots.SnapshotStateList
+
+class Navigator(startDestination: Any) {
+ val backStack : SnapshotStateList = mutableStateListOf(startDestination)
+
+ fun goTo(destination: Any){
+ backStack.add(destination)
+ }
+
+ fun goBack(){
+ backStack.removeLastOrNull()
+ }
+}
+```
+
+```
+package com.example.nav3recipes.modular.koin
+
+import org.koin.androidx.scope.dsl.activityRetainedScope
+import org.koin.dsl.module
+
+val appModule = module {
+ includes(profileModule,conversationModule)
+
+ activityRetainedScope {
+ scoped {
+ Navigator(startDestination = ConversationList)
+ }
+ }
+}
+```
+
+```
+package com.example.nav3recipes.modular.koin
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Scaffold
+import androidx.compose.ui.Modifier
+import androidx.navigation3.ui.NavDisplay
+import com.example.nav3recipes.ui.setEdgeToEdgeConfig
+import org.koin.android.ext.android.inject
+import org.koin.android.scope.AndroidScopeComponent
+import org.koin.androidx.compose.navigation3.getEntryProvider
+import org.koin.androidx.scope.activityRetainedScope
+import org.koin.core.Koin
+import org.koin.core.annotation.KoinExperimentalAPI
+import org.koin.core.component.KoinComponent
+import org.koin.core.scope.Scope
+import org.koin.dsl.koinApplication
+
+/**
+ * This recipe demonstrates how to use a modular approach with Navigation 3,
+ * where different parts of the application are defined in separate modules and injected
+ * into the main app using Koin.
+ *
+ * Features (Conversation and Profile) are split into two modules:
+ * - api: defines the public facing routes for this feature
+ * - impl: defines the entryProviders for this feature, these are injected into the app's main activity
+ * The common module defines:
+ * - a common navigator class that exposes a back stack and methods to modify that back stack
+ * - a type that should be used by feature modules to inject entryProviders into the app's main activity
+ * The app module creates the navigator by supplying a start destination and provides this navigator
+ * to the rest of the app module (i.e. MainActivity) and the feature modules.
+ */
+@OptIn(KoinExperimentalAPI::class)
+class KoinModularActivity : ComponentActivity(), AndroidScopeComponent, KoinComponent {
+ // Local Koin Context Instance
+ companion object {
+ private val localKoin = koinApplication {
+ modules(appModule)
+ }.koin
+ }
+ // Override default Koin context to use the local one
+ override fun getKoin(): Koin = localKoin
+ override val scope : Scope by activityRetainedScope()
+ val navigator: Navigator by inject()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setEdgeToEdgeConfig()
+ setContent {
+ Scaffold { paddingValues ->
+ NavDisplay(
+ backStack = navigator.backStack,
+ modifier = Modifier.padding(paddingValues),
+ onBack = { navigator.goBack() },
+ entryProvider = getEntryProvider()
+ )
+ }
+ }
+ }
+
+}
+```
diff --git a/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/multiple-backstacks.md b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/multiple-backstacks.md
new file mode 100644
index 00000000..81cd7947
--- /dev/null
+++ b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/multiple-backstacks.md
@@ -0,0 +1,436 @@
+# Multiple back stacks recipe
+
+This recipe demonstrates how to create multiple back stacks.
+
+The app has three top level routes: `RouteA`, `RouteB` and `RouteC`. These routes have sub routes `RouteA1`, `RouteB1` and `RouteC1` respectively. The content for the sub routes is a counter that can be used to verify state retention through configuration changes and process death.
+
+The app's navigation state is held in the `NavigationState` class. The state itself is created using `rememberNavigationState`.
+
+Navigation events are handled by the `Navigator`. It updates the navigation state.
+
+The navigation state is converted into `NavEntry`s with `NavigationState.toDecoratedEntries`. These entries are then displayed by `NavDisplay`.
+
+Key behaviors:
+
+- This app follows the "exit through home" pattern where the user always exits through the starting back stack. This means that `RouteA`'s entries are *always* in the list of entries.
+- Navigating to a top level route that is not the starting route *replaces* the other entries. For example, navigating A-\>B-\>C would result in entries for A+C, B's entries are removed.
+
+Important implementation details:
+
+- Each top level route has its own `SaveableStateHolderNavEntryDecorator`. This is the object responsible for managing the state for the entries in its back stack.
+
+[ Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/multiplestacks)
+
+```
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.nav3recipes.multiplestacks
+
+import androidx.navigation3.runtime.NavKey
+
+/**
+ * Handles navigation events (forward and back) by updating the navigation state.
+ */
+class Navigator(val state: NavigationState){
+ fun navigate(route: NavKey){
+ if (route in state.backStacks.keys){
+ // This is a top level route, just switch to it
+ state.topLevelRoute = route
+ } else {
+ state.backStacks[state.topLevelRoute]?.add(route)
+ }
+ }
+
+ fun goBack(){
+ val currentStack = state.backStacks[state.topLevelRoute] ?:
+ error("Stack for ${state.topLevelRoute} not found")
+ val currentRoute = currentStack.last()
+
+ // If we're at the base of the current route, go back to the start route stack.
+ if (currentRoute == state.topLevelRoute){
+ state.topLevelRoute = state.startRoute
+ } else {
+ currentStack.removeLastOrNull()
+ }
+ }
+}
+```
+
+```
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.nav3recipes.multiplestacks
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSerializable
+import androidx.compose.runtime.setValue
+import androidx.navigation3.runtime.NavBackStack
+import androidx.navigation3.runtime.NavEntry
+import androidx.navigation3.runtime.NavKey
+import androidx.navigation3.runtime.rememberDecoratedNavEntries
+import androidx.navigation3.runtime.rememberNavBackStack
+import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
+import androidx.navigation3.runtime.serialization.NavKeySerializer
+import androidx.savedstate.compose.serialization.serializers.MutableStateSerializer
+
+/**
+ * Create a navigation state that persists config changes and process death.
+ *
+ * @param startRoute - The top level route to start on. This should also be in `topLevelRoutes`.
+ * @param topLevelRoutes - The top level routes in the app.
+ */
+@Composable
+fun rememberNavigationState(
+ startRoute: NavKey,
+ topLevelRoutes: Set
+): NavigationState {
+
+ val topLevelRoute = rememberSerializable(
+ startRoute, topLevelRoutes,
+ serializer = MutableStateSerializer(NavKeySerializer())
+ ) {
+ mutableStateOf(startRoute)
+ }
+
+ // Create a back stack for each top level route.
+ val backStacks = topLevelRoutes.associateWith { key -> rememberNavBackStack(key) }
+
+ return remember(startRoute, topLevelRoutes) {
+ NavigationState(
+ startRoute = startRoute,
+ topLevelRoute = topLevelRoute,
+ backStacks = backStacks
+ )
+ }
+}
+
+/**
+ * State holder for navigation state. This class does not modify its own state. It is designed
+ * to be modified using the `Navigator` class.
+ *
+ * @param startRoute - the start route. The user will exit the app through this route.
+ * @param topLevelRoute - the state object that backs the top level route.
+ * @param backStacks - the back stacks for each top level route.
+ */
+class NavigationState(
+ val startRoute: NavKey,
+ topLevelRoute: MutableState,
+ val backStacks: Map>
+) {
+
+ /**
+ * The top level route.
+ */
+ var topLevelRoute: NavKey by topLevelRoute
+
+ /**
+ * Convert the navigation state into `NavEntry`s that have been decorated with a
+ * `SaveableStateHolder`.
+ *
+ * @param entryProvider - the entry provider used to convert the keys in the
+ * back stacks to `NavEntry`s.
+ */
+ @Composable
+ fun toDecoratedEntries(
+ entryProvider: (NavKey) -> NavEntry
+ ): List> {
+
+ // For each back stack, create a `SaveableStateHolder` decorator and use it to decorate
+ // the entries from that stack. When backStacks changes, `rememberDecoratedNavEntries` will
+ // be recomposed and a new list of decorated entries is returned.
+ val decoratedEntries = backStacks.mapValues { (_, stack) ->
+ val decorators = listOf(
+ rememberSaveableStateHolderNavEntryDecorator(),
+ )
+ rememberDecoratedNavEntries(
+ backStack = stack,
+ entryDecorators = decorators,
+ entryProvider = entryProvider
+ )
+ }
+
+ // Only return the entries for the stacks that are currently in use.
+ return getTopLevelRoutesInUse()
+ .flatMap { decoratedEntries[it] ?: emptyList() }
+ }
+
+ /**
+ * Get the top level routes that are currently in use. The start route is always the first route
+ * in the list. This means the user will always exit the app through the starting route
+ * ("exit through home" pattern). The list will contain a maximum of one other route. This is a
+ * design decision. In your app, you may wish to allow more than two top level routes to be
+ * active.
+ *
+ * Note that even if a top level route is not in use its state is still retained.
+ *
+ * @return the current top level routes that are in use.
+ */
+ private fun getTopLevelRoutesInUse() : List =
+ if (topLevelRoute == startRoute) {
+ listOf(startRoute)
+ } else {
+ listOf(startRoute, topLevelRoute)
+ }
+}
+```
+
+```
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.nav3recipes.multiplestacks
+
+import android.annotation.SuppressLint
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Camera
+import androidx.compose.material.icons.filled.Face
+import androidx.compose.material.icons.filled.Home
+import androidx.compose.material3.Icon
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.remember
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.navigation3.runtime.NavKey
+import androidx.navigation3.runtime.entryProvider
+import androidx.navigation3.ui.NavDisplay
+import com.example.nav3recipes.ui.setEdgeToEdgeConfig
+import kotlinx.serialization.Serializable
+
+
+@Serializable
+data object RouteA : NavKey
+
+@Serializable
+data object RouteA1 : NavKey
+
+@Serializable
+data object RouteB : NavKey
+
+@Serializable
+data object RouteB1 : NavKey
+
+@Serializable
+data object RouteC : NavKey
+
+@Serializable
+data object RouteC1 : NavKey
+
+private val TOP_LEVEL_ROUTES = mapOf(
+ RouteA to NavBarItem(icon = Icons.Default.Home, description = "Route A"),
+ RouteB to NavBarItem(icon = Icons.Default.Face, description = "Route B"),
+ RouteC to NavBarItem(icon = Icons.Default.Camera, description = "Route C"),
+)
+
+data class NavBarItem(
+ val icon: ImageVector,
+ val description: String
+)
+
+class MultipleStacksActivity : ComponentActivity() {
+ @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
+ override fun onCreate(savedInstanceState: Bundle?) {
+ setEdgeToEdgeConfig()
+ super.onCreate(savedInstanceState)
+ setContent {
+ val navigationState = rememberNavigationState(
+ startRoute = RouteA,
+ topLevelRoutes = TOP_LEVEL_ROUTES.keys
+ )
+
+ val navigator = remember { Navigator(navigationState) }
+
+ val entryProvider = entryProvider {
+ featureASection(onSubRouteClick = { navigator.navigate(RouteA1) })
+ featureBSection(onSubRouteClick = { navigator.navigate(RouteB1) })
+ featureCSection(onSubRouteClick = { navigator.navigate(RouteC1) })
+ }
+
+ Scaffold(bottomBar = {
+ NavigationBar {
+ TOP_LEVEL_ROUTES.forEach { (key, value) ->
+ val isSelected = key == navigationState.topLevelRoute
+ NavigationBarItem(
+ selected = isSelected,
+ onClick = { navigator.navigate(key) },
+ icon = {
+ Icon(
+ imageVector = value.icon,
+ contentDescription = value.description
+ )
+ },
+ label = { Text(value.description) }
+ )
+ }
+ }
+ }) {
+ NavDisplay(
+ entries = navigationState.toDecoratedEntries(entryProvider),
+ onBack = { navigator.goBack() }
+ )
+ }
+ }
+ }
+}
+```
+
+```
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.nav3recipes.multiplestacks
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.lifecycle.compose.dropUnlessResumed
+import androidx.navigation3.runtime.EntryProviderScope
+import androidx.navigation3.runtime.NavKey
+import com.example.nav3recipes.content.ContentGreen
+import com.example.nav3recipes.content.ContentMauve
+import com.example.nav3recipes.content.ContentOrange
+import com.example.nav3recipes.content.ContentPink
+import com.example.nav3recipes.content.ContentPurple
+import com.example.nav3recipes.content.ContentRed
+
+fun EntryProviderScope.featureASection(
+ onSubRouteClick: () -> Unit,
+) {
+ entry {
+ ContentRed("Route A") {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Button(onClick = dropUnlessResumed(block = onSubRouteClick)) {
+ Text("Go to A1")
+ }
+ }
+ }
+ }
+ entry {
+ ContentPink("Route A1") {
+ var count by rememberSaveable {
+ mutableIntStateOf(0)
+ }
+
+ Button(onClick = { count++ }) {
+ Text("Value: $count")
+ }
+ }
+ }
+}
+
+fun EntryProviderScope.featureBSection(
+ onSubRouteClick: () -> Unit,
+) {
+ entry {
+ ContentGreen("Route B") {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Button(onClick = dropUnlessResumed(block = onSubRouteClick)) {
+ Text("Go to B1")
+ }
+ }
+ }
+ }
+ entry {
+ ContentPurple("Route B1") {
+ var count by rememberSaveable {
+ mutableIntStateOf(0)
+ }
+ Button(onClick = { count++ }) {
+ Text("Value: $count")
+ }
+ }
+ }
+}
+
+fun EntryProviderScope.featureCSection(
+ onSubRouteClick: () -> Unit,
+) {
+ entry {
+ ContentMauve("Route C") {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Button(onClick = dropUnlessResumed(block = onSubRouteClick)) {
+ Text("Go to C1")
+ }
+ }
+ }
+ }
+ entry {
+ ContentOrange("Route C1") {
+ var count by rememberSaveable {
+ mutableIntStateOf(0)
+ }
+
+ Button(onClick = { count++ }) {
+ Text("Value: $count")
+ }
+ }
+ }
+}
+```
\ No newline at end of file
diff --git a/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/passingarguments.md b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/passingarguments.md
new file mode 100644
index 00000000..2640a22b
--- /dev/null
+++ b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/passingarguments.md
@@ -0,0 +1,371 @@
+# Passing Arguments to ViewModels (Hilt)
+
+This recipe demonstrates how to pass navigation arguments (keys) to a `ViewModel` using Hilt for dependency injection.
+
+## How it works
+
+This example uses Dagger/Hilt's assisted injection feature:
+
+1. The `ViewModel` is annotated with `@HiltViewModel` and its constructor uses `@AssistedInject` to receive the navigation key (which is annotated with `@Assisted`).
+2. An `@AssistedFactory` interface is defined to create the `ViewModel`.
+3. The `hiltViewModel` composable function is used to obtain the `ViewModel` instance. A `creationCallback` is provided to pass the navigation key to the factory, making it available to the `ViewModel`.
+
+**Note** : The `rememberViewModelStoreNavEntryDecorator` is added to the `NavDisplay`'s `entryDecorators`. This ensures that `ViewModel`s are correctly scoped to their corresponding `NavEntry`, so that a new `ViewModel` instance is created for each unique navigation key.
+[ Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/passingarguments/viewmodels/hilt)
+
+```
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.nav3recipes.passingarguments.viewmodels.hilt
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.remember
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.compose.dropUnlessResumed
+import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
+import androidx.navigation3.runtime.entryProvider
+import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
+import androidx.navigation3.ui.NavDisplay
+import com.example.nav3recipes.content.ContentBlue
+import com.example.nav3recipes.content.ContentGreen
+import com.example.nav3recipes.passingarguments.viewmodels.basic.RouteB
+import com.example.nav3recipes.ui.setEdgeToEdgeConfig
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import dagger.hilt.android.AndroidEntryPoint
+import dagger.hilt.android.lifecycle.HiltViewModel
+
+data object RouteA
+data class RouteB(val id: String)
+
+@AndroidEntryPoint
+class HiltViewModelsActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ setEdgeToEdgeConfig()
+ super.onCreate(savedInstanceState)
+ setContent {
+ val backStack = remember { mutableStateListOf(RouteA) }
+
+ NavDisplay(
+ backStack = backStack,
+ onBack = { backStack.removeLastOrNull() },
+
+ // In order to add the `ViewModelStoreNavEntryDecorator` (see comment below for why)
+ // we also need to add the default `NavEntryDecorator`s as well. These provide
+ // extra information to the entry's content to enable it to display correctly
+ // and save its state.
+ entryDecorators = listOf(
+ rememberSaveableStateHolderNavEntryDecorator(),
+ rememberViewModelStoreNavEntryDecorator()
+ ),
+ entryProvider = entryProvider {
+ entry {
+ ContentGreen("Welcome to Nav3") {
+ LazyColumn {
+ items(10) { i ->
+ Button(onClick = dropUnlessResumed {
+ backStack.add(RouteB("$i"))
+ }) {
+ Text("$i")
+ }
+ }
+ }
+ }
+ }
+ entry { key ->
+ val viewModel = hiltViewModel(
+ // Note: We need a new ViewModel for every new RouteB instance. Usually
+ // we would need to supply a `key` String that is unique to the
+ // instance, however, the ViewModelStoreNavEntryDecorator (supplied
+ // above) does this for us, using `NavEntry.contentKey` to uniquely
+ // identify the viewModel.
+ //
+ // tl;dr: Make sure you use rememberViewModelStoreNavEntryDecorator()
+ // if you want a new ViewModel for each new navigation key instance.
+ creationCallback = { factory ->
+ factory.create(key)
+ }
+ )
+ ScreenB(viewModel = viewModel)
+ }
+ }
+ )
+ }
+ }
+}
+
+@Composable
+fun ScreenB(viewModel: RouteBViewModel) {
+ ContentBlue("Route id: ${viewModel.navKey.id} ")
+}
+
+@HiltViewModel(assistedFactory = RouteBViewModel.Factory::class)
+class RouteBViewModel @AssistedInject constructor(
+ @Assisted val navKey: RouteB
+) : ViewModel() {
+
+ @AssistedFactory
+ interface Factory {
+ fun create(navKey: RouteB): RouteBViewModel
+ }
+}
+```
+
+# Passing Arguments to ViewModels (Basic)
+
+This recipe demonstrates how to pass navigation arguments (keys) to a `ViewModel` using a custom `ViewModelProvider.Factory`.
+
+## How it works
+
+1. A custom `ViewModelProvider.Factory` is created that takes the navigation key as a constructor parameter.
+2. Inside the `entry` composable, `viewModel(factory = ...)` is used to create the `ViewModel` instance, passing the current navigation key to the factory. This makes the navigation key available to the `ViewModel`.
+
+**Note** : The `rememberViewModelStoreNavEntryDecorator` is added to the `NavDisplay`'s `entryDecorators`. This ensures that `ViewModel`s are correctly scoped to their corresponding `NavEntry`, so that a new `ViewModel` instance is created for each unique navigation key.
+[ Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/passingarguments/viewmodels/basic)
+
+```
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.nav3recipes.passingarguments.viewmodels.basic
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.remember
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.compose.dropUnlessResumed
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
+import androidx.navigation3.runtime.entryProvider
+import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
+import androidx.navigation3.ui.NavDisplay
+import com.example.nav3recipes.content.ContentBlue
+import com.example.nav3recipes.content.ContentGreen
+import com.example.nav3recipes.ui.setEdgeToEdgeConfig
+
+data object RouteA
+
+data class RouteB(val id: String)
+
+class BasicViewModelsActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ setEdgeToEdgeConfig()
+ super.onCreate(savedInstanceState)
+ setContent {
+ val backStack = remember { mutableStateListOf(RouteA) }
+
+ NavDisplay(
+ backStack = backStack,
+ onBack = { backStack.removeLastOrNull() },
+ // In order to add the `ViewModelStoreNavEntryDecorator` (see comment below for why)
+ // we also need to add the default `NavEntryDecorator`s as well. These provide
+ // extra information to the entry's content to enable it to display correctly
+ // and save its state.
+ entryDecorators = listOf(
+ rememberSaveableStateHolderNavEntryDecorator(),
+ rememberViewModelStoreNavEntryDecorator()
+ ),
+ entryProvider = entryProvider {
+ entry {
+ ContentGreen("Welcome to Nav3") {
+ LazyColumn {
+ items(10) { i ->
+ Button(onClick = dropUnlessResumed {
+ backStack.add(RouteB("$i"))
+ }) {
+ Text("$i")
+ }
+ }
+ }
+ }
+ }
+ entry { key ->
+ // Note: We need a new ViewModel for every new RouteB instance. Usually
+ // we would need to supply a `key` String that is unique to the
+ // instance, however, the ViewModelStoreNavEntryDecorator (supplied
+ // above) does this for us, using `NavEntry.contentKey` to uniquely
+ // identify the viewModel.
+ //
+ // tl;dr: Make sure you use rememberViewModelStoreNavEntryDecorator()
+ // if you want a new ViewModel for each new navigation key instance.
+ ScreenB(viewModel = viewModel(factory = RouteBViewModel.Factory(key)))
+ }
+ }
+ )
+ }
+ }
+}
+
+@Composable
+fun ScreenB(viewModel: RouteBViewModel = viewModel()) {
+ ContentBlue("Route id: ${viewModel.key.id} ")
+}
+
+class RouteBViewModel(
+ val key: RouteB
+) : ViewModel() {
+ class Factory(
+ private val key: RouteB,
+ ) : ViewModelProvider.Factory {
+ override fun create(modelClass: Class): T {
+ return RouteBViewModel(key) as T
+ }
+ }
+}
+```
+
+# Passing Arguments to ViewModels (Koin)
+
+This recipe demonstrates how to pass navigation arguments (keys) to a `ViewModel` using Koin for dependency injection.
+
+## How it works
+
+1. A Koin module is defined that provides the `ViewModel`.
+2. The `koinViewModel` composable function is used to get the `ViewModel` instance.
+3. The navigation key is passed to the `ViewModel`'s constructor using `parametersOf(key)`. This makes the navigation key available to the `ViewModel`.
+
+**Note** : The `rememberViewModelStoreNavEntryDecorator` is added to the `NavDisplay`'s `entryDecorators`. This ensures that `ViewModel`s are correctly scoped to their corresponding `NavEntry`, so that a new `ViewModel` instance is created for each unique navigation key.
+[ Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/passingarguments/viewmodels/koin)
+
+```
+package com.example.nav3recipes.passingarguments.viewmodels.koin
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.remember
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.compose.dropUnlessResumed
+import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
+import androidx.navigation3.runtime.entryProvider
+import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
+import androidx.navigation3.ui.NavDisplay
+import com.example.nav3recipes.content.ContentBlue
+import com.example.nav3recipes.content.ContentGreen
+import com.example.nav3recipes.ui.setEdgeToEdgeConfig
+import org.koin.compose.KoinApplication
+import org.koin.compose.viewmodel.koinViewModel
+import org.koin.core.module.dsl.viewModelOf
+import org.koin.core.parameter.parametersOf
+import org.koin.dsl.koinConfiguration
+import org.koin.dsl.module
+
+data object RouteA
+data class RouteB(val id: String)
+
+class KoinViewModelsActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+
+ setEdgeToEdgeConfig()
+ super.onCreate(savedInstanceState)
+ setContent {
+ val backStack = remember { mutableStateListOf(RouteA) }
+
+ // Koin Compose Entry point
+ KoinApplication(
+ configuration = koinConfiguration {
+ modules(appModule)
+ }
+ ) {
+ NavDisplay(
+ backStack = backStack,
+ onBack = { backStack.removeLastOrNull() },
+
+ // In order to add the `ViewModelStoreNavEntryDecorator` (see comment below for why)
+ // we also need to add the default `NavEntryDecorator`s as well. These provide
+ // extra information to the entry's content to enable it to display correctly
+ // and save its state.
+ entryDecorators = listOf(
+ rememberSaveableStateHolderNavEntryDecorator(),
+ rememberViewModelStoreNavEntryDecorator()
+ ),
+ entryProvider = entryProvider {
+ entry {
+ ContentGreen("Welcome to Nav3") {
+ LazyColumn {
+ items(10) { i ->
+ Button(onClick = dropUnlessResumed {
+ backStack.add(RouteB("$i"))
+ }) {
+ Text("$i")
+ }
+ }
+ }
+ }
+ }
+ entry { key ->
+ val viewModel = koinViewModel {
+ parametersOf(key)
+ }
+ ScreenB(viewModel = viewModel)
+ }
+ }
+ )
+ }
+ }
+ }
+}
+
+// Local Koin Module
+private val appModule = module {
+ viewModelOf(::RouteBViewModel)
+}
+
+@Composable
+fun ScreenB(viewModel: RouteBViewModel) {
+ ContentBlue("Route id: ${viewModel.navKey.id} ")
+}
+
+class RouteBViewModel(val navKey: RouteB) : ViewModel()
+```
\ No newline at end of file
diff --git a/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/results-event.md b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/results-event.md
new file mode 100644
index 00000000..a61736f5
--- /dev/null
+++ b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/results-event.md
@@ -0,0 +1,272 @@
+# Returning a Result (Event-Based)
+
+This recipe demonstrates how to return a result from one screen to a previous screen using an event-based approach.
+
+## How it works
+
+This example uses a `ResultEventBus` to facilitate communication between the screens.
+
+1. **ResultEventBusNavEntryDecorator** : A `NavEntryDecorator` that provides a `ResultEventBus` via `LocalResultEventBus`.
+2. **`ResultEventBus`** : A `ResultEventBus` is created and made available to the composables via `LocalResultEventBus`. This EventBus sends and receives the results.
+3. **Sending the result** : The screen that produces the result calls `resultBus.sendResult(person)` to send the data back as a one-time event.
+4. **Receiving the result** : The screen that needs the result uses a `ResultEffect` composable to listen for results of a specific type. When a result is received, the effect's lambda is triggered.
+
+This approach is useful for results that are transient and should be handled as one-time events.
+[ Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/results/event)
+
+```
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.nav3recipes.results.common
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+
+class HomeViewModel : ViewModel() {
+ var person by mutableStateOf(null)
+}
+```
+
+```
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.nav3recipes.results.common
+
+import androidx.navigation3.runtime.NavKey
+import kotlinx.serialization.Serializable
+
+@Serializable
+data object Home : NavKey
+
+@Serializable
+class PersonDetailsForm : NavKey
+```
+
+```
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.nav3recipes.results.common
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class Person(val name: String, val favoriteColor: String) : Parcelable
+```
+
+```
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.nav3recipes.results.common
+
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.text.input.rememberTextFieldState
+import androidx.compose.material3.Button
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.dropUnlessResumed
+import com.example.nav3recipes.content.ContentBlue
+import com.example.nav3recipes.content.ContentGreen
+
+@Composable
+fun HomeScreen(
+ person: Person?,
+ onNext: () -> Unit
+) {
+ ContentBlue("Hello ${person?.name ?: "unknown person"}") {
+
+ if (person != null) {
+ Text("Your favorite color is ${person.favoriteColor}")
+ }
+
+ Spacer(Modifier.height(16.dp))
+ Button(onClick = dropUnlessResumed(block = onNext)) {
+ Text("Tell us about yourself")
+ }
+ }
+}
+
+@Composable
+fun PersonDetailsScreen(
+ onSubmit: (Person) -> Unit
+) {
+ ContentGreen("About you") {
+
+ val nameTextState = rememberTextFieldState()
+ OutlinedTextField(
+ state = nameTextState,
+ label = { Text("Please enter your name") }
+ )
+
+ val favoriteColorTextState = rememberTextFieldState()
+ OutlinedTextField(
+ state = favoriteColorTextState,
+ label = { Text("Please enter your favorite color") }
+ )
+
+ Button(
+ onClick = dropUnlessResumed {
+ val person = Person(
+ name = nameTextState.text.toString(),
+ favoriteColor = favoriteColorTextState.text.toString()
+ )
+ onSubmit(person)
+ },
+ enabled = nameTextState.text.isNotBlank() &&
+ favoriteColorTextState.text.isNotBlank()
+ ) {
+ Text("Submit")
+ }
+ }
+}
+```
+
+```
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.nav3recipes.results.event
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation3.runtime.entryProvider
+import androidx.navigation3.runtime.rememberNavBackStack
+import androidx.navigation3.runtime.result.LocalResultEventBus
+import androidx.navigation3.runtime.result.ResultEffect
+import androidx.navigation3.runtime.result.ResultEventBus
+import androidx.navigation3.runtime.result.rememberResultEventBusNavEntryDecorator
+import androidx.navigation3.ui.NavDisplay
+import com.example.nav3recipes.results.common.Home
+import com.example.nav3recipes.results.common.HomeScreen
+import com.example.nav3recipes.results.common.HomeViewModel
+import com.example.nav3recipes.results.common.Person
+import com.example.nav3recipes.results.common.PersonDetailsForm
+import com.example.nav3recipes.results.common.PersonDetailsScreen
+import com.example.nav3recipes.ui.setEdgeToEdgeConfig
+
+class ResultEventActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ setEdgeToEdgeConfig()
+ super.onCreate(savedInstanceState)
+
+ setContent {
+ Scaffold { paddingValues ->
+
+ val backStack = rememberNavBackStack(Home)
+
+ NavDisplay(
+ backStack = backStack,
+ modifier = Modifier.padding(paddingValues),
+ onBack = { backStack.removeLastOrNull() },
+ entryDecorators = listOf(rememberResultEventBusNavEntryDecorator()),
+ entryProvider = entryProvider {
+ entry {
+ val viewModel = viewModel(key = Home.toString())
+ ResultEffect { person ->
+ viewModel.person = person
+ }
+
+ val person = viewModel.person
+ HomeScreen(
+ person = person,
+ onNext = { backStack.add(PersonDetailsForm()) }
+ )
+ }
+ entry {
+ val resultBus = LocalResultEventBus.current
+ PersonDetailsScreen(
+ onSubmit = { person ->
+ resultBus.sendResult(result = person)
+ backStack.removeLastOrNull()
+ }
+ )
+ }
+ }
+ )
+ }
+ }
+ }
+}
+```
\ No newline at end of file
diff --git a/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/results-state.md b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/results-state.md
new file mode 100644
index 00000000..71c2ccfa
--- /dev/null
+++ b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/results-state.md
@@ -0,0 +1,266 @@
+# Returning a Result (State-Based)
+
+This recipe demonstrates how to return a result from one screen to a previous screen using a state-based approach.
+
+## How it works
+
+This example uses a `ResultEventBus` to manage the result as state.
+
+1. **ResultEventBusNavEntryDecorator** : A `NavEntryDecorator` that provides a `ResultEventBus` via `LocalResultEventBus`.
+2. **`ResultEventBus`** : A `ResultEventBus` is created and made available to the composables via `LocalResultEventBus`. This EventBus sends and receives the results.
+3. **Setting the result** : The screen that produces the result calls `resultBus.sendResult(person)` to send the data back.
+4. **Observing the result** : The screen that needs the result calls `resultBus.conflateAsState()` to get a `State` object representing the result. The UI then observes this state and recomposes whenever the result changes.
+
+This approach is suitable when only the latest result is required. The result state does not survive configuration change or process death.
+[ Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/results/state)
+
+```
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.nav3recipes.results.common
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+
+class HomeViewModel : ViewModel() {
+ var person by mutableStateOf(null)
+}
+```
+
+```
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.nav3recipes.results.common
+
+import androidx.navigation3.runtime.NavKey
+import kotlinx.serialization.Serializable
+
+@Serializable
+data object Home : NavKey
+
+@Serializable
+class PersonDetailsForm : NavKey
+```
+
+```
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.nav3recipes.results.common
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class Person(val name: String, val favoriteColor: String) : Parcelable
+```
+
+```
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.nav3recipes.results.common
+
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.text.input.rememberTextFieldState
+import androidx.compose.material3.Button
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.dropUnlessResumed
+import com.example.nav3recipes.content.ContentBlue
+import com.example.nav3recipes.content.ContentGreen
+
+@Composable
+fun HomeScreen(
+ person: Person?,
+ onNext: () -> Unit
+) {
+ ContentBlue("Hello ${person?.name ?: "unknown person"}") {
+
+ if (person != null) {
+ Text("Your favorite color is ${person.favoriteColor}")
+ }
+
+ Spacer(Modifier.height(16.dp))
+ Button(onClick = dropUnlessResumed(block = onNext)) {
+ Text("Tell us about yourself")
+ }
+ }
+}
+
+@Composable
+fun PersonDetailsScreen(
+ onSubmit: (Person) -> Unit
+) {
+ ContentGreen("About you") {
+
+ val nameTextState = rememberTextFieldState()
+ OutlinedTextField(
+ state = nameTextState,
+ label = { Text("Please enter your name") }
+ )
+
+ val favoriteColorTextState = rememberTextFieldState()
+ OutlinedTextField(
+ state = favoriteColorTextState,
+ label = { Text("Please enter your favorite color") }
+ )
+
+ Button(
+ onClick = dropUnlessResumed {
+ val person = Person(
+ name = nameTextState.text.toString(),
+ favoriteColor = favoriteColorTextState.text.toString()
+ )
+ onSubmit(person)
+ },
+ enabled = nameTextState.text.isNotBlank() &&
+ favoriteColorTextState.text.isNotBlank()
+ ) {
+ Text("Submit")
+ }
+ }
+}
+```
+
+```
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.nav3recipes.results.state
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Scaffold
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation3.runtime.entryProvider
+import androidx.navigation3.runtime.rememberNavBackStack
+import androidx.navigation3.runtime.result.LocalResultEventBus
+import androidx.navigation3.runtime.result.ResultEffect
+import androidx.navigation3.runtime.result.rememberResultEventBusNavEntryDecorator
+import androidx.navigation3.ui.NavDisplay
+import com.example.nav3recipes.results.common.Home
+import com.example.nav3recipes.results.common.HomeScreen
+import com.example.nav3recipes.results.common.HomeViewModel
+import com.example.nav3recipes.results.common.Person
+import com.example.nav3recipes.results.common.PersonDetailsForm
+import com.example.nav3recipes.results.common.PersonDetailsScreen
+import com.example.nav3recipes.ui.setEdgeToEdgeConfig
+
+class ResultStateActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ setEdgeToEdgeConfig()
+ super.onCreate(savedInstanceState)
+
+ setContent {
+ Scaffold { paddingValues ->
+ val backStack = rememberNavBackStack(Home)
+ NavDisplay(
+ backStack = backStack,
+ modifier = Modifier.padding(paddingValues),
+ onBack = { backStack.removeLastOrNull() },
+ entryDecorators = listOf(rememberResultEventBusNavEntryDecorator()),
+ entryProvider = entryProvider {
+ entry {
+ val resultState = LocalResultEventBus
+ .current
+ .conflateAsState(null)
+ val person = resultState.value
+ HomeScreen(
+ person = person,
+ onNext = { backStack.add(PersonDetailsForm()) }
+ )
+ }
+ entry {
+ val resultBus = LocalResultEventBus.current
+ PersonDetailsScreen(
+ onSubmit = { person ->
+ resultBus.sendResult(result = person)
+ backStack.removeLastOrNull()
+ }
+ )
+ }
+ }
+ )
+ }
+ }
+ }
+}
+```
\ No newline at end of file
diff --git a/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/scenes-listdetail.md b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/scenes-listdetail.md
new file mode 100644
index 00000000..32063b35
--- /dev/null
+++ b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/scenes-listdetail.md
@@ -0,0 +1,435 @@
+# List-Detail Scene Recipe
+
+This example shows how to create a list-detail layout using the Scenes API.
+
+A `ListDetailSceneStrategy` will return a `ListDetailScene` if:
+
+- the window width is over 600dp
+- A `Detail` entry is the last item in the back stack
+- A `List` entry is in the back stack
+
+The `ListDetailScene` provides a `CompositionLocal` named `LocalBackButtonVisibility` that can be used by the detail `NavEntry` to control whether it displays a back button. This is useful when the detail entry usually displays a back button but should not display it when being displayed in a `ListDetailScene`. See for more details on this use case.
+
+See `ListDetailScene.kt` for more implementation details.
+[ Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/scenes/listdetail)
+
+```
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.nav3recipes.scenes.listdetail
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.navigation3.runtime.NavEntry
+import androidx.navigation3.runtime.NavMetadataKey
+import androidx.navigation3.runtime.contains
+import androidx.navigation3.runtime.metadata
+import androidx.navigation3.scene.Scene
+import androidx.navigation3.scene.SceneStrategy
+import androidx.navigation3.scene.SceneStrategyScope
+import androidx.window.core.layout.WindowSizeClass
+import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND
+
+/**
+ * A [Scene] that displays a list and a detail [NavEntry] side-by-side in a 40/60 split.
+ *
+ */
+data class ListDetailScene(
+ override val key: Any,
+ override val previousEntries: List>,
+ val listEntry: NavEntry,
+ val detailEntry: NavEntry,
+) : Scene {
+ override val entries: List> = listOf(listEntry, detailEntry)
+ override val content: @Composable (() -> Unit) = {
+ Row(modifier = Modifier.fillMaxSize()) {
+ Column(modifier = Modifier.weight(0.4f)) {
+ listEntry.Content()
+ }
+
+ // Let the detail entry know not to display a back button.
+ CompositionLocalProvider(LocalBackButtonVisibility provides false) {
+ Column(modifier = Modifier.weight(0.6f)) {
+ AnimatedContent(
+ targetState = detailEntry,
+ contentKey = { entry -> entry.contentKey },
+ transitionSpec = {
+ slideInHorizontally(
+ initialOffsetX = { it }
+ ) togetherWith
+ slideOutHorizontally(targetOffsetX = { -it })
+ }
+ ) { entry ->
+ entry.Content()
+ }
+ }
+ }
+ }
+ }
+
+ companion object {
+ /**
+ * Helper function to add metadata to a [NavEntry] indicating it can be displayed
+ * in the list pane of a [ListDetailScene].
+ */
+ fun listPane() = metadata {
+ put(ListKey, true)
+ }
+
+ /**
+ * Helper function to add metadata to a [NavEntry] indicating it can be displayed
+ * in the detail pane of a the [ListDetailScene].
+ */
+ fun detailPane() = metadata {
+ put(DetailKey, true)
+ }
+ }
+
+ object ListKey : NavMetadataKey
+ object DetailKey : NavMetadataKey
+}
+
+/**
+ * This `CompositionLocal` can be used by a detail `NavEntry` to decide whether to display
+ * a back button. Default is `true`. It is set to `false` for a detail `NavEntry` when being
+ * displayed in a `ListDetailScene`.
+ */
+val LocalBackButtonVisibility = compositionLocalOf { true }
+
+@Composable
+fun rememberListDetailSceneStrategy(): ListDetailSceneStrategy {
+ val windowSizeClass = currentWindowAdaptiveInfoV2().windowSizeClass
+
+ return remember(windowSizeClass) {
+ ListDetailSceneStrategy(windowSizeClass)
+ }
+}
+
+
+/**
+ * A [SceneStrategy] that returns a [ListDetailScene] if:
+ *
+ * - the window width is over 600dp
+ * - A `Detail` entry is the last item in the back stack
+ * - A `List` entry is in the back stack
+ *
+ * Notably, when the detail entry changes the scene's key does not change. This allows the scene,
+ * rather than the NavDisplay, to handle animations when the detail entry changes.
+ */
+class ListDetailSceneStrategy(val windowSizeClass: WindowSizeClass) : SceneStrategy {
+
+ override fun SceneStrategyScope.calculateScene(entries: List>): Scene? {
+
+ if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) {
+ return null
+ }
+
+ val detailEntry =
+ entries.lastOrNull()?.takeIf { it.metadata.contains(ListDetailScene.DetailKey) }
+ ?: return null
+ val listEntry =
+ entries.findLast { it.metadata.contains(ListDetailScene.ListKey) } ?: return null
+
+ // We use the list's contentKey to uniquely identify the scene.
+ // This allows the detail panes to be animated in and out by the scene, rather than
+ // having NavDisplay animate the whole scene out when the selected detail item changes.
+ val sceneKey = listEntry.contentKey
+
+ return ListDetailScene(
+ key = sceneKey,
+ previousEntries = entries.dropLast(1),
+ listEntry = listEntry,
+ detailEntry = detailEntry
+ )
+ }
+}
+```
+
+```
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.nav3recipes.scenes.listdetail
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.animation.SharedTransitionLayout
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Scaffold
+import androidx.compose.ui.Modifier
+import androidx.navigation3.runtime.NavBackStack
+import androidx.navigation3.runtime.NavKey
+import androidx.navigation3.runtime.entryProvider
+import androidx.navigation3.runtime.rememberNavBackStack
+import androidx.navigation3.ui.NavDisplay
+import com.example.nav3recipes.ui.setEdgeToEdgeConfig
+import kotlinx.serialization.Serializable
+
+/**
+ * This example shows how to create a list-detail layout using the Scenes API.
+ *
+ * A `ListDetailScene` will render content in two panes if:
+ *
+ * - the window width is over 600dp
+ * - A `Detail` entry is the last item in the back stack
+ * - A `List` entry is in the back stack
+ *
+ * @see `ListDetailScene`
+ */
+@Serializable
+data object ConversationList : NavKey
+
+@Serializable
+data class ConversationDetail(
+ val id: Int,
+ val colorId: Int
+) : NavKey
+
+@Serializable
+data object Profile : NavKey
+
+class ListDetailActivity : ComponentActivity() {
+
+ @OptIn(ExperimentalSharedTransitionApi::class)
+ override fun onCreate(savedInstanceState: Bundle?) {
+ setEdgeToEdgeConfig()
+ super.onCreate(savedInstanceState)
+
+ setContent {
+
+ Scaffold { paddingValues ->
+
+ val backStack = rememberNavBackStack(ConversationList)
+ val listDetailStrategy = rememberListDetailSceneStrategy()
+
+ SharedTransitionLayout {
+ NavDisplay(
+ backStack = backStack,
+ onBack = { backStack.removeLastOrNull() },
+ sceneStrategies = listOf(listDetailStrategy),
+ sharedTransitionScope = this,
+ modifier = Modifier.padding(paddingValues),
+ entryProvider = entryProvider {
+ entry(
+ metadata = ListDetailScene.listPane()
+ ) {
+ ConversationListScreen(
+ onConversationClicked = { detailRoute ->
+ backStack.addDetail(detailRoute)
+ }
+ )
+ }
+ entry(
+ metadata = ListDetailScene.detailPane()
+ ) { conversationDetail ->
+ ConversationDetailScreen(
+ conversationDetail = conversationDetail,
+ onBack = { backStack.removeLastOrNull() },
+ onProfileClicked = { backStack.add(Profile) }
+ )
+ }
+ entry {
+ ProfileScreen()
+ }
+ }
+ )
+ }
+ }
+ }
+ }
+}
+
+private fun NavBackStack.addDetail(detailRoute: ConversationDetail) {
+
+ // Remove any existing detail routes before adding this detail route.
+ // In certain scenarios, such as when multiple detail panes can be shown at once, it may
+ // be desirable to keep existing detail routes on the back stack.
+ removeIf { it is ConversationDetail }
+ add(detailRoute)
+}
+```
+
+```
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.nav3recipes.scenes.listdetail
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.dropUnlessResumed
+import com.example.nav3recipes.ui.theme.colors
+
+@Composable
+fun ConversationListScreen(
+ onConversationClicked: (ConversationDetail) -> Unit
+) {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surface),
+ ) {
+ items(10) { index ->
+ val conversationId = index + 1
+ val conversationDetail = ConversationDetail(
+ id = conversationId,
+ colorId = conversationId % colors.size
+ )
+ val backgroundColor = colors[conversationDetail.colorId]
+ ListItem(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = dropUnlessResumed {
+ onConversationClicked(conversationDetail)
+ }),
+ headlineContent = {
+ Text(
+ text = "Conversation $conversationId",
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ },
+ colors = ListItemDefaults.colors(
+ containerColor = backgroundColor // Set container color directly
+ )
+ )
+ }
+ }
+}
+
+@Composable
+fun ConversationDetailScreen(
+ conversationDetail: ConversationDetail,
+ onBack: () -> Unit,
+ onProfileClicked: () -> Unit
+) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(colors[conversationDetail.colorId])
+ .padding(16.dp)
+ ) {
+ if (LocalBackButtonVisibility.current) {
+ IconButton(
+ onClick = onBack,
+ modifier = Modifier.align(Alignment.TopStart)
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Back"
+ )
+ }
+ }
+ Column(
+ modifier = Modifier
+ .fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = "Conversation Detail Screen: ${conversationDetail.id}",
+ style = MaterialTheme.typography.headlineMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Button(onClick = dropUnlessResumed(block = onProfileClicked)) {
+ Text("View Profile")
+ }
+ }
+ }
+}
+
+@Composable
+fun ProfileScreen() {
+ val profileColor = MaterialTheme.colorScheme.surfaceVariant
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(profileColor)
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = "Profile Screen",
+ style = MaterialTheme.typography.headlineMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+}
+```
\ No newline at end of file
diff --git a/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/scenes-twopane.md b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/scenes-twopane.md
new file mode 100644
index 00000000..fed0bf90
--- /dev/null
+++ b/.claude/skills/navigation-3/references/android/guide/navigation/navigation-3/recipes/scenes-twopane.md
@@ -0,0 +1,244 @@
+# Two-Pane Scene Recipe
+
+This example shows how to create a two pane layout using the Scenes API.
+
+A `TwoPaneSceneStrategy` will return a `TwoPaneScene` if:
+
+- the window width is over 600dp
+- the last two nav entries on the back stack have indicated that they support being displayed in a `TwoPaneScene` in their metadata.
+
+See `TwoPaneScene.kt` for more implementation details.
+[ Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/scenes/twopane)
+
+```
+package com.example.nav3recipes.scenes.twopane
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.navigation3.runtime.NavEntry
+import androidx.navigation3.runtime.NavMetadataKey
+import androidx.navigation3.runtime.contains
+import androidx.navigation3.runtime.metadata
+import androidx.navigation3.scene.Scene
+import androidx.navigation3.scene.SceneStrategy
+import androidx.navigation3.scene.SceneStrategyScope
+import androidx.window.core.layout.WindowSizeClass
+import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND
+
+// --- TwoPaneScene ---
+/**
+ * A custom [Scene] that displays two [NavEntry]s side-by-side in a 50/50 split.
+ */
+data class TwoPaneScene(
+ override val key: Any,
+ override val previousEntries: List>,
+ val firstEntry: NavEntry,
+ val secondEntry: NavEntry
+) : Scene {
+ override val entries: List> = listOf(firstEntry, secondEntry)
+ override val content: @Composable (() -> Unit) = {
+ Row(modifier = Modifier.fillMaxSize()) {
+ Column(modifier = Modifier.weight(0.5f)) {
+ firstEntry.Content()
+ }
+ Column(modifier = Modifier.weight(0.5f)) {
+ secondEntry.Content()
+ }
+ }
+ }
+
+ companion object {
+ /**
+ * Helper function to add metadata to a [NavEntry] indicating it can be displayed
+ * in a two-pane layout.
+ */
+ fun twoPane() = metadata {
+ put(TwoPaneKey, true)
+ }
+ }
+
+ object TwoPaneKey : NavMetadataKey
+}
+
+@Composable
+fun rememberTwoPaneSceneStrategy(): TwoPaneSceneStrategy {
+ val windowSizeClass = currentWindowAdaptiveInfoV2().windowSizeClass
+
+ return remember(windowSizeClass) {
+ TwoPaneSceneStrategy(windowSizeClass)
+ }
+}
+
+
+// --- TwoPaneSceneStrategy ---
+/**
+ * A [SceneStrategy] that activates a [TwoPaneScene] if the window is wide enough
+ * and the top two back stack entries declare support for two-pane display.
+ */
+class TwoPaneSceneStrategy(val windowSizeClass: WindowSizeClass) : SceneStrategy {
+
+ override fun SceneStrategyScope.calculateScene(entries: List>): Scene? {
+
+ // Condition 1: Only return a Scene if the window is sufficiently wide to render two panes.
+ // We use isWidthAtLeastBreakpoint with WIDTH_DP_MEDIUM_LOWER_BOUND (600dp).
+ if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) {
+ return null
+ }
+
+ val lastTwoEntries = entries.takeLast(2)
+
+ // Condition 2: Only return a Scene if there are two entries, and both have declared
+ // they can be displayed in a two pane scene.
+ return if (lastTwoEntries.size == 2
+ && lastTwoEntries.all { it.metadata.contains(TwoPaneScene.TwoPaneKey) }
+ ) {
+ val firstEntry = lastTwoEntries.first()
+ val secondEntry = lastTwoEntries.last()
+
+ // The scene key must uniquely represent the state of the scene.
+ // A Pair of the first and second entry keys ensures uniqueness.
+ val sceneKey = Pair(firstEntry.contentKey, secondEntry.contentKey)
+
+ TwoPaneScene(
+ key = sceneKey,
+ // Where we go back to is a UX decision. In this case, we only remove the top
+ // entry from the back stack, despite displaying two entries in this scene.
+ // This is because in this app we only ever add one entry to the
+ // back stack at a time. It would therefore be confusing to the user to add one
+ // when navigating forward, but remove two when navigating back.
+ previousEntries = entries.dropLast(1),
+ firstEntry = firstEntry,
+ secondEntry = secondEntry
+ )
+
+ } else {
+ null
+ }
+ }
+
+
+}
+```
+
+```
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.nav3recipes.scenes.twopane
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.animation.SharedTransitionLayout
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.compose.dropUnlessResumed
+import androidx.navigation3.runtime.NavBackStack
+import androidx.navigation3.runtime.NavKey
+import androidx.navigation3.runtime.entryProvider
+import androidx.navigation3.runtime.rememberNavBackStack
+import androidx.navigation3.ui.NavDisplay
+import com.example.nav3recipes.content.ContentBase
+import com.example.nav3recipes.content.ContentGreen
+import com.example.nav3recipes.content.ContentRed
+import com.example.nav3recipes.ui.setEdgeToEdgeConfig
+import com.example.nav3recipes.ui.theme.colors
+import kotlinx.serialization.Serializable
+
+@Serializable
+private object Home : NavKey
+
+@Serializable
+private data class Product(val id: Int) : NavKey
+
+@Serializable
+private data object Profile : NavKey
+
+class TwoPaneActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ setEdgeToEdgeConfig()
+ super.onCreate(savedInstanceState)
+
+ setContent {
+ val backStack = rememberNavBackStack(Home)
+ val twoPaneStrategy = rememberTwoPaneSceneStrategy()
+
+ SharedTransitionLayout {
+ NavDisplay(
+ backStack = backStack,
+ onBack = { backStack.removeLastOrNull() },
+ sceneStrategies = listOf(twoPaneStrategy),
+ sharedTransitionScope = this,
+ entryProvider = entryProvider {
+ entry(
+ metadata = TwoPaneScene.twoPane()
+ ) {
+ ContentRed("Welcome to Nav3") {
+ Button(onClick = { backStack.addProductRoute(1) }) {
+ Text("View the first product")
+ }
+ }
+ }
+ entry(
+ metadata = TwoPaneScene.twoPane()
+ ) { product ->
+ ContentBase(
+ "Product ${product.id} ",
+ Modifier.background(colors[product.id % colors.size])
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Button(onClick = dropUnlessResumed {
+ backStack.addProductRoute(product.id + 1)
+ }) {
+ Text("View the next product")
+ }
+ Button(onClick = dropUnlessResumed {
+ backStack.add(Profile)
+ }) {
+ Text("View profile")
+ }
+ }
+ }
+ }
+ entry {
+ ContentGreen("Profile (single pane only)")
+ }
+ }
+ )
+ }
+ }
+ }
+}
+
+private fun NavBackStack.addProductRoute(productId: Int) {
+ val productRoute =
+ Product(productId)
+ // Avoid adding the same product route to the back stack twice.
+ if (!contains(productRoute)) {
+ add(productRoute)
+ }
+}
+```
\ No newline at end of file
diff --git a/.claude/skills/navigation-3/references/android/guide/navigation/type-safe-destinations.md b/.claude/skills/navigation-3/references/android/guide/navigation/type-safe-destinations.md
new file mode 100644
index 00000000..c5700003
--- /dev/null
+++ b/.claude/skills/navigation-3/references/android/guide/navigation/type-safe-destinations.md
@@ -0,0 +1,129 @@
+This guide outlines the process of replacing string-based routes with
+serializable Kotlin types to achieve compile-time safety and eliminate runtime
+crashes caused by typos or incorrect argument types.
+
+## Prerequisites
+
+Before starting the migration, verify that your project meets the following
+requirements:
+
+1. **Navigation version**: Update to Jetpack Navigation 2.8.0 or higher
+2. **Kotlin serialization plugin**:
+3. Add the plugin to `libs.versions.toml`:
+
+ [libraries]
+ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
+
+ [plugins]
+ kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
+
+- Add the dependencies to your top-level `build.gradle.kts` and module-level `build.gradle.kts`.
+
+## Step 1: Define Your Destinations
+
+Replace your constant route strings with `@Serializable` objects and classes.
+
+- **For screens without arguments** : Use a `data object`
+- **For screens with arguments** : Use a `data class`
+
+**Before (string based):**
+
+ const val ROUTE_HOME = "home"
+ const val ROUTE_PROFILE = "profile/{userId}"
+
+**After (type safe):**
+
+ import kotlinx.serialization.Serializable
+
+ @Serializable
+ object Home
+
+ @Serializable
+ data class Profile(val userId: String)
+
+## Step 2: Update the NavHost Configuration
+
+Update your `NavHost` to use the new generic types in the `composable` and
+`dialog` function.
+
+**Before:**
+
+ NavHost(navController, startDestination = "home") {
+ composable("home") { HomeScreen(...) }
+ composable("profile/{userId}") { backStackEntry ->
+ val userId = backStackEntry.arguments?.getString("userId")
+ ProfileScreen(userId)
+ }
+ }
+
+**After:**
+
+ NavHost(navController, startDestination = Home) {
+ composable {
+ HomeScreen(...)
+ }
+ composable { backStackEntry ->
+ // The library automatically handles argument extraction
+ val profile: Profile = backStackEntry.toRoute()
+ ProfileScreen(profile.userId)
+ }
+ }
+
+## Step 3: Implement Type-Safe Navigation Calls
+
+Replace string-interpolated navigation calls with class instances.
+
+**Before:**
+
+ navController.navigate("profile/user123")
+
+**After:**
+
+ navController.navigate(Profile(userId = "user123"))
+
+## Step 4: Accessing Arguments in ViewModels
+
+If you use a `ViewModel`, you can now extract the route object directly from the
+`SavedStateHandle`.
+
+**Implementation:**
+
+ class ProfileViewModel(
+ savedStateHandle: SavedStateHandle
+ ) : ViewModel() {
+ // Automatically parses arguments into the Profile class
+ private val profile = savedStateHandle.toRoute()
+ val userId = profile.userId
+ }
+
+## Step 5: (Advanced) Handling Custom Types
+
+If you need to pass complex data classes (not just primitives), you must define
+a custom `NavType`.
+
+1. **Create the Custom Type** : \`\`\`kotlin val SearchFilterType = object : NavType(isNullableAllowed = false) { override fun get(bundle: Bundle, key: String): SearchFilter? = Json.decodeFromString(bundle.getString(key) ?: return null)
+
+ override fun parseValue(value: String): SearchFilter =
+ Json.decodeFromString(Uri.decode(value))
+
+ override fun put(bundle: Bundle, key: String, value: SearchFilter) {
+ bundle.putString(key, Json.encodeToString(value))
+ }
+
+}
+
+
+
+ 2. **Register it in the Graph**:
+ ```kotlin
+ composable(
+ typeMap = mapOf(typeOf() to SearchFilterType)
+ ) { ... }
+
+## Best practices and tips
+
+- **Sealed Hierarchies**: For large apps, group your routes using a sealed interface or class to keep the navigation structure organized
+- **Object Instances** : For routes without parameters, always use `object` instead of `class` to avoid unnecessary allocations
+- **Nullable Types** : The new API supports nullable types (for example, `data
+ class Search(val query: String?)`) and provides default values automatically
+- **Testing** : Use `navController.currentBackStackEntry?.hasRoute()` to check the current destination in a type-safe manner during UI tests
\ No newline at end of file
diff --git a/.github/workflows/desktop_packaging.yml b/.github/workflows/desktop_packaging.yml
new file mode 100644
index 00000000..33a607fc
--- /dev/null
+++ b/.github/workflows/desktop_packaging.yml
@@ -0,0 +1,53 @@
+name: Desktop packaging
+
+# S4d-166: the smallest Desktop CI packaging gate (S4d-165 decision). Proves `:desktopApp` packages in CI —
+# a headless run witness + an UNSIGNED app image (`createDistributable`) — reusing the same Zulu 17 /
+# ubuntu-latest setup the Android workflows already use. Narrowly triggered (manual + Desktop-affecting PRs)
+# so it does not burden or block unrelated PRs. No signing / installer formats / app icon / version source /
+# macOS runner; no `compose.desktop.packaging.checkJdkVendor=false`. Zulu is the existing non-Homebrew CI
+# provisioning; the workflow run is the proof that it satisfies Compose Desktop's vendor guard (the local
+# Homebrew JDK is the known failing case).
+
+env:
+ GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:MaxMetaspaceSize=1g" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false
+
+on:
+ workflow_dispatch:
+ pull_request:
+ types: [opened, reopened, synchronize]
+ branches:
+ - master
+ paths:
+ - 'desktopApp/**'
+ - 'shared/**'
+ - 'buildSrc/**'
+ - 'gradle/**'
+ - 'build.gradle.kts'
+ - 'settings.gradle.kts'
+ - 'gradle.properties'
+ - 'gradlew'
+ - 'gradlew.bat'
+ - '.github/workflows/desktop_packaging.yml'
+
+jobs:
+ package:
+ name: Desktop run + unsigned app image
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-java@v4
+ with:
+ distribution: 'zulu'
+ java-version: 17
+ cache: 'gradle'
+ - uses: gradle/actions/setup-gradle@v4
+ with:
+ gradle-version: wrapper
+ gradle-home-cache-cleanup: true
+ # Cheap witness first: the shared engine + Desktop entry run headlessly (same gate every Desktop slice uses).
+ - name: Desktop headless run witness
+ run: ./gradlew :desktopApp:run --args='--headless'
+ # Unsigned Desktop app image (Linux app image + bundled JRE via jpackage). App-image mode needs only the
+ # JDK — no installer/system packages, no signing.
+ - name: Build unsigned Desktop app image (createDistributable)
+ run: ./gradlew :desktopApp:createDistributable
diff --git a/.github/workflows/pr_pre_check.yml b/.github/workflows/pr_pre_check.yml
index 4f2f3206..1f939473 100644
--- a/.github/workflows/pr_pre_check.yml
+++ b/.github/workflows/pr_pre_check.yml
@@ -1,5 +1,8 @@
name: PR Checks
+env:
+ GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:MaxMetaspaceSize=1g" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false
+
on:
pull_request:
types: [opened, reopened, synchronize]
@@ -21,15 +24,24 @@ concurrency:
jobs:
build:
- name: Compile (app:assembleDebug)
+ name: Compile + migration safety net (assembleDebug, :shared:desktopTest, app unit tests)
runs-on: ubuntu-latest
timeout-minutes: 30
- if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
+ # S4d-169 (merge of origin/master): adopt master's scaffolding (paths-ignore, workflow_dispatch,
+ # permissions, concurrency, wrapper validation, setup-android) BUT keep the migration safety net
+ # (full tests, not master's compile-only `-x test`). The master `if: !github.event.pull_request.draft`
+ # guard is deliberately DROPPED so Draft PR #358 still runs :shared:desktopTest + the non-strict
+ # watermark golden net (device-independent nonblank/geometry/decal/encode structure).
+ # S4d-172: the pinned-environment `WATERMARK_GOLDEN_STRICT=true` FNV gate was REMOVED from CI - its
+ # baselines are Robolectric-environment pixel hashes captured on a pinned host (see
+ # WatermarkExportGoldenTest's policy comment + ADR-0010) and cannot pass on GitHub Ubuntu/Temurin. The
+ # strict gate stays a LOCAL / pinned-env gate, run on the capture env or an intentional
+ # Robolectric/Skia/font bump via `WATERMARK_GOLDEN_STRICT=true ./gradlew :app:testDebugUnitTest`.
steps:
- name: Checkout
uses: actions/checkout@v4
with:
- fetch-depth: 1
+ fetch-depth: 0
persist-credentials: false
- name: Validate Gradle Wrapper
@@ -54,5 +66,11 @@ jobs:
- name: Make gradlew executable
run: chmod +x ./gradlew
- - name: Build debug APK (skip lint/tests)
- run: ./gradlew :app:assembleDebug -x lint -x test --stacktrace
+ - name: Build apk with Gradle
+ run: ./gradlew :app:assembleDebug
+
+ - name: Run :shared multiplatform tests (JVM, CMP plan C1.8)
+ run: ./gradlew :shared:desktopTest
+
+ - name: Run :app unit tests incl. watermark golden (Robolectric NATIVE, CMP plan C1.7/C1.8)
+ run: ./gradlew :app:testDebugUnitTest
diff --git a/.gitignore b/.gitignore
index 0ca30df7..c8d67271 100644
--- a/.gitignore
+++ b/.gitignore
@@ -111,5 +111,8 @@ hs_err_pid*
# End of https://www.toptal.com/developers/gitignore/api/android,kotlin
.idea/libraries-with-intellij-classes.xml
+.alma-snapshots
+.DS_Store
+.kotlin
.weblate
.weblate.ini
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..48f55c77
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,114 @@
+# AGENTS.md
+
+This file provides guidance to agents when working with code in this repository. `CLAUDE.md` is a symlink to this file for Claude Code compatibility.
+
+## What this app is
+
+EasyWatermark (`me.rosuh.easywatermark`) — a privacy-focused Android app that tiles text/image watermarks over photos so they can't be repurposed. Privacy promises that shape engineering decisions: fully offline, zero tracking/stats/crash SDKs, no permissions needed on API 29+ (pre-29 needs storage permission). Distributed via GitHub Releases, Google Play (paid, same code), F-Droid, Coolapk. Translations come from Weblate (13 locales) — don't hand-edit non-default `strings.xml`.
+
+## Current state: View→Compose done, KMP foundations landed — read this first
+
+**Current execution order (2026-06-27):** PR #358 remains a Draft integration checkpoint, not a merge-ready PR. Finish the full-platform release-grade KMP/CMP code migration before starting the final screenshot/recording-driven 1:1 UI/UX restoration. Android production v2.10.0 remains the only visual/behavioral source of truth; Android debug aligns to it first, then iOS/Desktop align to that Android baseline with explicit platform exceptions. Non-trivial implementation work must use the ACSP cowork loop (coordinator session -> worker execution -> bounded poll -> coordinator review/accept/requeue); direct coordinator edits are limited to tiny durable-context hygiene or explicit user requests.
+
+1. **View→Compose migration** — **functionally COMPLETE** as of 2026-06-13 (branch `feat/compose-about-share-parity`, PR #377 → `feat/migrate_to_compose`). `ComposeMainActivity` (a `ComponentActivity`) is the **sole** Activity: launcher + Navigation Compose (LaunchScreen → GalleryDialog → EditorScreen), `ACTION_SEND`/`ACTION_SEND_MULTIPLE` share-in, and the crash-recovery screen (`RecoveryScreen.kt`, gated on `MyApp.recoveryMode`). The legacy stack — `MainActivity`, `AboutActivity`, `OpenSourceActivity`, all `ui/dialog/*`, `ui/panel/*`, `ui/adapter/*`, `ui/base/*`, and the `LaunchView`/edge-effect widgets — was **deleted** (ADR-0016, 39 .kt files). `EditorScreen` now renders its watermark preview with a **Compose `Canvas`** (`WaterMarkCanvas`, S3c-2) that reuses the shared `WatermarkRenderer`; the last surviving legacy View `WaterMarkImageView` and the `ViewInfo` export-scale chain were **retired** (S3c-3) — there is no remaining `AndroidView`-bridged renderer. S3d removed the remaining orphaned gallery layout (`item_image_gallery.xml`) and its unused async inflater wrapper.
+2. **Compose Multiplatform / KMP migration** (phases C1–C6): `docs/superpowers/plans/2026-06-12-cmp-migration-plan.md`. Decisions in `docs/adr/`; domain vocabulary in `docs/CONTEXT.md`; dependency-level KMP classifications in `docs/superpowers/research/2026-06-12-cmp-readiness-audit.json`. **Foundations now landed (2026-06-13), not just planned:**
+ - **`:shared` KMP module EXISTS, compiles for all 3 platforms, and RUNS on 2** — `kotlin-multiplatform` with `androidTarget()` + `jvm("desktop")` + `iosArm64()` + `iosSimulatorArm64()`; pure-Kotlin only (no Compose/Android resources yet → sidesteps CMP-9547). `commonMain` holds platform-neutral domain types `ImageFormat`/`Result`/`JobState`/`MediaRef`/`ImageInfo`, the watermark config model (`WaterMark`, `WatermarkMode`, `TextTypeface`, `TextPaintStyle`; S4d-60), the config normalization rules (`WatermarkConfigRules`; S4d-61), **and the engine geometry core `render/WatermarkGeometry`** (gap/diagonal/rotated-cell-AABB math, faithfully extracted from the legacy watermark cell math formerly in `WaterMarkImageView`, unit-tested in `commonTest`). Compiles for Android + JVM/desktop + iOS; the same code **runs** on Android (`:app`) and Desktop (`:desktopApp` — `./gradlew :desktopApp:run` executes the shared engine core). `commonTest` runs on every PR (CI). Add platform-neutral domain/engine code here.
+ - **C2a cell-sizing DONE (text + icon, verified):** both `WatermarkRenderer.buildTextShader` (rotated-AABB + gap) and `buildIconShader` (diagonal + gap) delegate cell sizing to commonMain `WatermarkGeometry`; the old `adjustHorizonalGap`/`adjustVerticalGap`/`calculateMaxSize` are deleted. Since both builders are shared by the Compose `Canvas` preview AND `MainViewModel.generateImage` (export), the shared sizing core drives **both** paths. Verified render-identical on-device (S22+ preview parity 0/227/46.8) + goldens (dimension, pixel, icon-equivalence) + `:shared` tests.
+ - **Compose `Canvas` preview swap DONE (S3c-2/S3c-3):** the editor preview is now a Compose `Canvas` (`EditorScreen.WaterMarkCanvas`) that calls `WatermarkRenderer.build*Shader` + `compose` on the native canvas; the legacy `WaterMarkImageView` View and the `ViewInfo`/`onViewInfoChanged`/`viewInfoStateFlow` export-scale chain are deleted, and goldens call `WatermarkRenderer` directly.
+ - **CommonMain renderer bootstrap STARTED (S4d-2…S4d-8):** `shared/src/commonMain/.../render/WatermarkCellComposer.kt` now has an offscreen `ImageBitmap` cell scaffold, a text-raster primitive `composeTextCell`, and an icon-raster primitive `composeIconCell`. Text rastering is driven by injected `TextRasterEnv` (`FontFamily.Resolver` + `Density` + `LayoutDirection`) and `WatermarkTextContent`, uses `TextMeasurer` + `MultiParagraph.paint`, sizes through shared `WatermarkGeometry`, rotates around the cell centre, and centres the measured text box via `((finalWidth - textWidth)/2, (finalHeight - textHeight)/2)`. Icon rastering accepts an already-decoded Compose `ImageBitmap`, scales from `textSize / 14f`, sizes through `WatermarkGeometry.diagonal` + gaps, pre-scales with `FilterQuality.None` (nearest), then uses **float** pivot + **float** top-left point-draw (S4d-7) following Android's *placement math*, and applies normalized/clamped alpha. (The final point-draw has no `filterQuality`, so it is NOT byte-identical to Android's bare-paint rotated `drawBitmap` for non-uniform icons — see the next bullet.) Desktop tests prove visible text/icon pixels, geometry sizing, gap behavior, placement/centring, rotation, and alpha behavior. `app/src/test/.../WatermarkRendererCommonParityTest.kt` is the Android-vs-commonMain cell parity gate (icon dims/alpha/footprint + text dims/nonblank for solid/axis-aligned cases — it does NOT assert rotated non-uniform byte equality). This is **not wired into production**; Android preview/export still use `WatermarkRenderer`.
+ - **Icon production draw-swap: DECIDED NOT to pursue on Android (S4d-6→S4d-8, Option A, ADR-0004 addendum).** Routing Android `buildIconShader` through `composeIconCell` is byte-identical for solid/axis-aligned icons but **cannot** match `android.graphics.Canvas.drawBitmap` byte-for-byte for **rotated non-uniform** icons (e.g. the production default 315°) — commonMain Compose has no `drawImage` overload offering float-placement AND nearest-filter together (S4d-7 reduced the strict-golden delta 16→5 px but not to 0; `nativeCanvas.drawBitmap` is the only API with both, and it is platform-specific). So **Android icon production/export stays native** (`WatermarkRenderer.buildIconShader`, strict-golden-protected); `composeIconCell` is the **Desktop/iOS** icon renderer. Do NOT re-attempt the Android byte-exact icon swap; changing this needs an explicit owner decision (tolerance/perceptual golden policy, or a commonMain nearest rotated blitter).
+ - **Bundled text font env accepted test-only (S4d-16):** exact Noto Sans Latin regular + Noto Sans CJK SC regular artifacts are now test-only resources behind `TextRasterEnv` for desktopTest/androidTest; no production font loader, compose-resources, Android text draw-swap, or golden rebaseline was authorized. Corrected on-device bundled-both-sides evidence: Latin and bold are byte-identical, italic/bold-italic are near (`IoU 0.921/0.934`), and CJK remains the only large engine gap (`cjk_0 0.716`, `cjk_multiline 0.691`) even with the font controlled. Latin-first avoids the CJK-first ~2x metric artifact but still shifts line metrics a few px.
+ - **Text production policy: Android stays native (S4d-17 Option C):** the S4d-17 decision pack accepted that Android watermark text should keep using native `WatermarkRenderer.buildTextShader` / `StaticLayout` indefinitely; bundled commonMain `composeTextCell` is the Desktop/iOS text renderer first. The CJK gap is a `StaticLayout` vs `MultiParagraph` engine difference, not a font gap, so an Android text draw-swap would not improve Android CJK; it would create a visible zh-locale change and force a golden rebaseline. Android CJK parity remains log-only unless the owner explicitly reopens an Android draw-swap with a signed perceptual tolerance. Do NOT attempt Android text production draw-swap by default.
+ - **Desktop text renderer realized (S4d-18):** `shared/src/desktopMain/.../DesktopWatermarkTextRenderer.kt` is the first non-test use of the bundled commonMain text path — desktop Skiko `createFontFamilyResolver()` + the bundled Latin+CJK font loaded from `desktopMain/resources/fonts/` (Skiko byte-`Font` factory, **no compose-resources**), rendering through shared `WatermarkCellComposer.composeTextCell`. `:desktopApp:run` renders Latin/CJK/multiline/rotated cells to PNGs; `DesktopTextRendererGoldenTest` is the Desktop perceptual/stability gate. `compose.desktop.currentOs` (Skiko) is a **desktopMain (desktop-target-only)** dep — it does NOT leak to `:app` (verified: 0 skiko in `:app:debugRuntimeClasspath`). Desktop fonts now live in `desktopMain/resources/fonts/` (de-duplicated from the S4d-16 desktopTest copies; the test loads `fonts/` from the classpath). On Skiko, latin-first `FontFamily(latin, cjk)` per-glyph falls back to the CJK face (CJK renders real glyphs). Android text production stays native (unchanged).
+ - **Desktop composition realized (S4d-19):** commonMain `WatermarkCellComposer.composeOverBackground(background, cell, tileMode, offsetX, offsetY, alpha)` is the platform-neutral analogue of Android `WatermarkRenderer.compose` — REPEAT grid-tiles the rendered cell from origin across an already-decoded background `ImageBitmap`; CLAMP draws one decal at a fractional offset (reuses the commonMain `WatermarkTileMode` enum). desktopMain `DesktopWatermarkComposer` supplies a deterministic asset-free sample background + AWT PNG encode + a Compose-free `ComposedImage` holder; `:desktopApp:run` writes `build/s4d19-desktop-watermark/{repeat,clamp}_watermark.png`; `DesktopWatermarkCompositionGoldenTest` gates dims/tiled-vs-localized/determinism/PNG. Decode/encode stays platform-side (no image-decode in commonMain). **Android composition stays native** (`WatermarkRenderer.compose`) — `composeOverBackground` is the Desktop/iOS path, NOT wired into Android; routing Android onto it was assessed in **S4d-190 and decided NO-GO** (parity unprovable read-only — `BitmapShader`+`drawRect` vs a `drawImage` grid/decal loop; preview can't map; low value since the cell raster stays native), so **Android production composition stays native**; reopening needs an explicit owner decision starting with a **test-only Robolectric NATIVE FNV measurement** (no production routing without that gate).
+ - **Desktop real-image decode realized (S4d-20A):** desktopMain `DesktopImageDecoder` decodes a real encoded image via AWT `ImageIO.read` → `BufferedImage.toComposeImageBitmap()` and feeds it to the commonMain `composeOverBackground`; `DesktopWatermarkComposer.composeOverRealImage(imageBytes, …)` is the end-to-end Desktop pipeline (decode → render cell → compose → encode), `sampleBackgroundPng(…)` is a Compose-free deterministic fixture (no binary asset). `:desktopApp:run` writes `build/s4d20-desktop-real-image/{source_fixture,real_image_watermark}.png`; `DesktopRealImageDecodeGoldenTest` gates it. commonMain stays **decode-free** (already-decoded `ImageBitmap` in, composed out); decode/encode are platform-side. No new dependency (`ImageIO` is JDK; `toComposeImageBitmap` is the desktop-target-only `compose.desktop.currentOs`); `:app` stays skiko-clean. Android decode/render stays native. **(S4d-21)** `DesktopImageDecoder` now also bakes **JPEG EXIF orientation** at the decode edge (matching Android's EXIF-baked policy): a tiny pure-JDK APP1/TIFF reader (`parseExifOrientation`, best-effort → 1) + AWT `AffineTransformOp` rotation/flip (`applyExifOrientation`, all 8; 5–8 swap dims), so `composeOverRealImage` gets an upright image. Still **no new dependency** (no metadata library — pure `javax.imageio`/`java.awt`); the `androidx.exifinterface` in `:app` is the pre-existing Android lib, unrelated. Gated by `DesktopExifOrientationTest` (generated EXIF fixtures, no binary asset). **(S4d-22)** `DesktopExifOrientationTest` was widened (test-only) to directly assert the EXIF **mirror family** — 2 mirror-H, 4 mirror-V, 5 transpose, 7 transverse (with dimension-swap checks for 5/7) — over the S4d-21 production transforms (suite 12/0); the malformed-IFD-offset guard is retained. No production change.
+ - **iOS renderer boundaries landed (S4d-20B/S4d-118/S4d-192/S4d-193):** `shared/src/iosMain` now mirrors the desktop boundaries — `IosImageDecoder` (Skia `Image.makeFromEncoded` → `toComposeImageBitmap()`), `IosTextRasterEnv` (skiko `createFontFamilyResolver()` + `bundledFontFamily(latinBytes, cjkBytes, latinFirst)` via the skiko byte-`Font` factory, **no compose-resources**), `IosWatermarkRenderer` (text: `renderTextCell` + `composeOverImage` + Skia PNG encode; icon: **S4d-115** `renderIconCell` + `composeIconOverImage`, reusing commonMain `composeIconCell`, `IosImageDecoder`, and `composeOverBackground`). iOS is a Skiko backend like desktop, so the accepted commonMain pipeline runs unchanged. **No new dependency** (Skia ships with the compose iOS klibs); commonMain stays decode-free; `:app` stays skiko/ios-clean. iOS font BYTES are a caller responsibility (no JVM classpath on Native; compose-resources forbidden) — a real iOS app feeds them from `NSBundle`/`NSData` at C5. **Proof = compile (both iOS targets) + native test-executable LINK (`linkDebugTest*`).** *(Update — S4d-55, 2026-06-26: the `iosSimulatorArm64Test` RUN is no longer deferred — it RAN on the now-installed iOS 27.0 simulator and passed 41/41, including `IosWatermarkRendererTest`; S4d-115 then added two more iOS icon tests and kept strict Android goldens 51/0.)* Android decode/render stays native. **S4d-115 alpha rule:** iOS icon cells are rendered opaque and watermark alpha is applied once at composition; this is perceptual iOS/Skiko honoring, not Android native `buildIconShader` byte parity. **S4d-116 persistence boundary:** iOS icon bytes are copied to app-private `NSDocumentDirectory/watermark_icons/icon_` and persisted as `MediaRef(path)` through `IosWatermarkConfigBridge.setIconFromBytes`; cleanup only deletes helper-owned filenames (prefix + non-empty suffix with no `/`). **S4d-117 render branch:** `WatermarkWorkflow` renders Text mode through the existing text path and Image mode through persisted icon bytes + `IosWatermarkRenderBridge.renderIconWatermarkedPng`; missing/unreadable icon fails visibly. **S4d-118 picker UI:** SwiftUI has a separate icon `PhotosPicker` that passes picked bytes to `setIconFromBytes`, shows Text/Image mode plus a transient thumbnail, and re-renders the current source image; Swift never parses/persists icon paths. **S4d-192/S4d-193 safety net:** `IosWatermarkRendererGoldenTest` is the iOS Skiko sibling of `DesktopTextRendererGoldenTest`, running under `:shared:iosSimulatorArm64Test` with coarse 8x8 perceptual/stability signatures. S4d-192 covers Latin, CJK, multiline, rotated text, and rotated icon cells (non-blank + deterministic); S4d-193 covers text/icon composition over an opaque background with changed-pixel signatures for REPEAT, CLAMP, REPEAT!=CLAMP, and icon composition. No byte-exact golden, bundled-font assertion, or Android renderer parity claim.
+ - **iOS EXIF orientation: already handled by Skia decode — NO transform needed (S4d-23).** Unlike Android (`BitmapFactory`) and Desktop (`ImageIO`), which return stored pixels and bake EXIF manually, **skiko's `Image.makeFromEncoded().toComposeImageBitmap()` already bakes EXIF orientation** (orientation-6 JPEG → upright 16×24/TR). Proven at runtime on the desktop skiko proxy `SkiaExifDecodeProbeTest` (same `org.jetbrains.skia` API as iOS). So `IosImageDecoder` stays pure-Skia (S4d-20B) — applying a manual rotation would DOUBLE-rotate. The iOS gate `IosExifOrientationTest` asserts decode is upright — **and as of S4d-55 (2026-06-26) it RAN on the iOS 27.0 simulator and PASSED, confirming skiko bakes EXIF on Native, so no `IosImageDecoder` fix was needed** (was compile/link-proven only before). A speculative commonMain `ExifOrientation.apply` was built and DELETED once the proxy proved Skia bakes orientation; it stays deleted (the iOS run confirmed skiko bakes — restore only if a future iOS run ever shows otherwise).
+ - **iOS NSBundle font loader landed (S4d-20C-a):** `shared/src/iosMain/.../IosFontLoader.kt` is the iOS resource-acquisition boundary — `loadFontBytes(name, type, bundle = NSBundle.mainBundle)` reads a bundled font via `NSBundle.pathForResource` → `NSData.dataWithContentsOfFile` → `ByteArray` (pin + `platform.posix.memcpy`, `@OptIn(ExperimentalForeignApi)`), and `bundledFontFamily(...)` loads Latin+CJK and delegates to the **unchanged** byte-core `IosTextRasterEnv.bundledFontFamily`. Loud failure (missing/unreadable/empty → `IllegalStateException`). **No new dependency** (Kotlin/Native bundled interop only), no compose-resources, no binary font asset in `iosMain`. Compile + native test-executable LINK proven on both iOS targets (`IosFontLoaderTest`). *(Update — S4d-55, 2026-06-26: `IosFontLoaderTest` now RUNS on the iOS 27.0 simulator (3/3 pass), and the real `.app` font packaging is proven — the built `iosApp.app` bundles `NotoSans-Regular.ttf` + `NotoSansSC-Regular.otf` as Copy Bundle Resources.)* The byte-array API stays the core renderer boundary.
+ - **Still ahead (C2 remainder):** ~~RUN the iOS proof (`iosSimulatorArm64Test`, incl. `IosExifOrientationTest` + `IosFontLoaderTest`) on an environment with an iOS runtime~~ *(DONE — S4d-55, 2026-06-26: ran on the iOS 27.0 simulator, 41/41 pass; `IosExifOrientationTest` green confirms skiko bakes EXIF on Native, no `IosImageDecoder` fix needed)*; **iOS cell + composition perceptual/stability gates exist (S4d-192/S4d-193)**; and **Android composition routing was decided NO-GO (S4d-190)** — production composition stays native (`WatermarkRenderer.compose`); reopening needs an owner decision + a test-only Robolectric NATIVE FNV measurement first. (Desktop EXIF orientation is DONE — all 8 transforms in S4d-21, mirror-family test coverage in S4d-22; iOS EXIF orientation is handled by Skia decode — S4d-23.) Android text/icon/composition/decode production remains native by default. `:desktopApp` renders watermark text cells (S4d-18), full tiled/decal sample images (S4d-19), and a real ImageIO-decoded EXIF-upright watermarked image (S4d-20A/S4d-21); iOS boundaries compile+link (S4d-20B). Still NOT the full Compose Desktop editor (C4); iosApp bring-up (PHPicker, photo store, memory, Compose UI) is C5. **iosApp bring-up STARTED (C5.1/C5.2/C5.4, build-only, in review — S4d-25/26/27/29/31/32):** an `iosApp` Xcode target links the dynamic `Shared.framework` (`shared/build.gradle.kts`), packages the exact Noto Latin+CJK fonts as Copy Bundle Resources, and compiles a SwiftUI `PhotosPicker → IosWatermarkRenderer.composeOverImage → encodePng → UIImage(data:)` flow with `ShareLink` + Save-to-Photos export (`Photos`/`PhotosUI` are system frameworks — no new dependency). S4d-31 added an iOS-only `@Throws` boundary `IosWatermarkRenderBridge` so font/decode/render/encode failures surface as a Swift `catch` (`IosRenderException`) → `WatermarkWorkflow.State.failure` instead of a fatal Kotlin/Native crash; S4d-32 replaced the per-byte Swift `Data`⇄`KotlinByteArray` copies with an iosMain `memcpy` bridge (`IosByteArrayInterop`). **C5.3 partially proven — S4d-55 (2026-06-26):** `:shared:iosSimulatorArm64Test` RAN on the now-installed iOS 27.0 simulator (41/41 pass) and the `iosApp` was built for the simulator SDK, installed, launched, and executed shared Kotlin/Native code live (on-screen witness `geometry.diagonal(100×100)=141`; `Shared.framework` + Noto fonts packaged in the `.app`). **C5.3 render+export UI now proven — S4d-58 (2026-06-26):** S4d-56 found no installed external tool could drive the picker; S4d-57 added an `iosAppUITests` XCUITest target and proved XCUITest **opens** the out-of-process `PHPickerViewController` grid but **cannot address its photo cells** on Xcode-27-beta / iOS-27 (`collectionViews` empty; `scrollViews.images` sees only the "Limited Access" banner). S4d-58 added a tiny `#if DEBUG`, `-uiTestFixtureImage`-gated UI-test seam in `ContentView` that feeds a deterministic in-memory PNG through the **real** `WatermarkWorkflow`/`IosWatermarkRenderBridge` path (NOT a faked preview), and XCUITest proved (2/0, viewed screenshots) **fixture image → watermarked preview ("Watermarked 720×480, PNG 21,438 B", 315° Latin+CJK tiling) → Save to Photos ("Saved to Photos") → Share (system share sheet)**. S4d-58 r1 hardened the asserts (Save fails on "Save failed" + requires "Saved"; Share asserts the share sheet). **Still unproven (the one remaining step):** real **PHPicker grid-cell selection**, which the fixture seam deliberately bypasses — a beta-toolchain/system-UI automation limitation, NOT a product failure. The render engine itself is runtime-proven by the shared suite (S4d-55) and now has iOS cell + composition perceptual/stability gates (S4d-192/S4d-193).
+ - **Golden net for C2a:** JVM Robolectric-NATIVE cell golden (`WatermarkCellGoldenTest`, in CI) + on-device instrumented golden (`WatermarkCellInstrumentedGoldenTest`). NOTE both sample the cell sized to its own dims (a fixed small window misses large rotated/emoji cells). Verify renders by VIEWING the screenshot, not its byte size, and confirm `READ_MEDIA_IMAGES` is granted (share-in silently falls back to LaunchScreen without it).
+ - **Watermark model/rules neutralization started (S4d-60/S4d-61, accepted 2026-06-27):** `WaterMark`, `WatermarkMode`, `TextTypeface`, and `TextPaintStyle` now live in `shared/commonMain/.../data/model`. The old app-only `WaterMarkRepository.MarkMode` and `SerializableSealClass` are deleted. Android-specific behavior is confined to edge mappers: `WaterMark.obtainTileMode()` in `TileModeExt.kt` and `TextPaintStyle.obtainSysStyle()` in `TextStyleExt.kt`. `WatermarkConfigRules` now owns pure config normalization (text-size clamp, alpha conversion/clamp, h/v gap clamp, degree clamp, text/icon mode transitions) while `WaterMarkRepository` was still in `:app` at S4d-60/61 (it later moved to commonMain in S4d-84..S4d-89). DataStore ints for mode/style/typeface/tile and render behavior remain byte-identical, and strict goldens stayed green without rebaseline.
+ - **ImageInfo moved to commonMain (S4d-71, accepted 2026-06-27):** `ImageInfo` now lives in `shared/commonMain/.../data/model` with the same package/FQN and the app copy deleted. Its only Android coupling, the doc/lint-only range annotation on `offsetX`/`offsetY`, was dropped; the 0f..1f fractional-offset invariant remains documented. This completes the image identity / watermark-config platform-neutral model set. App-side `UserPreferences`, Room `Template`, and UI `FuncTitleModel` remain edge models.
+ - **Config-change vocabulary moved to commonMain (S4d-72, accepted 2026-06-27):** `FuncType` now lives in `shared/commonMain/.../data/model`, and `WatermarkConfigChange` is the typed command seam for text/icon/color/alpha/degree/size/typeface/tile/gap changes. `FuncTitleModel` stays app-side because it carries Android resource ids, but its `type` is shared. `MainViewModel.onWaterMarkChanged` keeps the temporary raw `Action.WaterMarkChange(item, any)` UI edge, maps it once through `WatermarkConfigChange.from`, and dispatches to the existing update methods.
+ - **First DataStore KMP code in `:shared` (S4d-74, accepted 2026-06-27, commit `59eb6e0`):** `shared/commonMain/.../data/datastore/CreateDataStore.kt` holds a driver-free helper `createDataStore(storage: Storage) = DataStoreFactory.create(storage)` (no Android imports, **no `commonMain expect`**), and `shared/androidMain/.../CreateDataStore.android.kt` holds a **plain function (NOT an `actual`)** `createPreferencesDataStore(context, name)` = `PreferenceDataStoreFactory.create(produceFile = preferencesDataStoreFile(name), migrations = SharedPreferencesMigration(context, name))`. `:app` `di/DataStoreModule.kt` keeps the `Context.userDataStore`/`waterMarkDataStore` property names (so `RepositoryModule`/`AppModule` are unchanged) and one store per file in-process. New catalog alias `datastore = androidx.datastore:datastore` (version `datastorePreference = 1.2.1`); `:shared` `commonMain` depends on both `datastore` + `datastore-preferences` (KMP klibs resolve for desktop + both iOS — compile-gated). Stored path/format/migration are byte-equivalent; strict goldens stayed 48/0 with no rebaseline. **Deliberately deferred:** there is NO `commonMain expect` (would force empty all-target actuals), NO iOS/desktop store creation, and Android does **not** route through the common storage helper (byte-identical legacy preferences creation needs `PreferenceDataStoreFactory.create(produceFile, migrations)`; building a byte-identical `Storage` would need the internal preferences serializer). Repositories stay Android-side at that point. The common prefs consumer landed in S4d-77; the iOS/desktop store creation then landed in S4d-78 below as plain per-platform functions (NOT an `expect/actual` promotion — that was deliberately not taken).
+ - **`UserPreferences` + `UserConfigRepository` now in commonMain (S4d-76 + S4d-77, accepted 2026-06-27, commits `94aaf90` + `e8e861e`):** **S4d-76** moved `UserPreferences` to `shared/commonMain/.../data/model` (same FQN `me.rosuh.easywatermark.data.model.UserPreferences`), dropped the Android-only `@Keep`, and inlined the default `UserPreferences(ImageFormat.JPEG, 80)` so the model no longer depends on `UserConfigRepository` (the orphaned `UserConfigRepository.DEFAULT_OUTPUT_FORMAT` was removed; `DEFAULT_COMPRESS_LEVEL=80` kept for the read clamp). **S4d-77** moved `UserConfigRepository` to `shared/commonMain/.../data/repo` (same FQN) — the **first real common DataStore Preferences consumer**. Its three Android edges were resolved with **no dependency change**: (1) `okio.IOException` replaces `java.io.IOException` (resolves in commonMain via the transitive `datastore-core-okio`; JVM `typealias` → identical Android behavior; compile-gated on desktop + iosSimulatorArm64 + iosArm64 + app); (2) `saveVersionCode(versionCode: Int)` keeps `BuildConfig.VERSION_CODE` at the Android caller edge (`MainViewModel.saveUpgradeInfo()`); (3) `KEY_CHANGE_LOG` inlines the byte-identical literal `"sp_water_mark_config_key_change_log"` (so `WaterMarkRepository` — then still in `:app` — is not a commonMain dependency at S4d-77; it later moved to commonMain in S4d-84..S4d-89). `RepositoryModule`/`DataStoreModule` resolve the same FQN unchanged; Android store creation still goes through the S4d-74 `createPreferencesDataStore` + `DataStoreModule`. Persisted bytes/keys unchanged (no migration); strict goldens 48/0, R8 release retained both classes. **Landed next in S4d-78 (below):** the iOS/desktop store creation. Room and templates remain Android-side at this point (`WaterMarkRepository` later moved to commonMain in S4d-84..S4d-89).
+ - **Desktop + iOS DataStore store creation (S4d-78, accepted 2026-06-27, commit `258ace1`; extended by S4d-120):** `shared/commonMain/.../data/datastore/CreateDataStore.kt` gained `createPreferencesDataStore(producePath: () -> okio.Path)` = `PreferenceDataStoreFactory.createWithPath(produceFile = …)` — the **public, serializer-free** common factory both okio-backed targets share. `shared/desktopMain/.../CreateDataStore.desktop.kt` has `createUserConfigDataStore(dir = ~/.easywatermark, name = UserConfigRepository.SP_NAME)` and, since **S4d-120**, `createWaterMarkDataStore(dir = ~/.easywatermark, name = WaterMarkRepository.SP_NAME)`; `shared/iosMain/.../CreateDataStore.ios.kt` has the matching iOS `createUserConfigDataStore(...)` plus the watermark store used by `IosWatermarkConfigBridge` (S4d-102), resolving `NSDocumentDirectory` (Foundation interop, `@OptIn(ExperimentalForeignApi)` for `NSURL.path`). **No dependency change** (okio + `createWithPath` come from the S4d-74 deps; all-target compile is the proof). **Still NO `commonMain expect`/`actual createDataStore`** — deliberately plain per-platform functions, because the platform signatures genuinely differ (Android needs `Context` + `SharedPreferencesMigration`, desktop needs a dir, iOS derives `NSDocumentDirectory`); this is intentional, not a stepping stone to an `expect`. **Android store creation is unchanged and byte-faithful** (`androidMain` untouched; strict goldens 48/0). Proof: all-target compile + a **Desktop** `UserConfigRepository` roundtrip test (`:shared:desktopTest`, 1/0), then a Desktop `WaterMarkRepository`/`WatermarkConfigEditor` roundtrip test in S4d-120; the iOS watermark store is runtime-proven through the iOS config bridge/editor tests. Desktop app-entry wiring landed in S4d-80 and S4d-120 (below); the iOS Swift-facing prefs bridge + iOS runtime roundtrip landed in S4d-81.
+ - **iOS UserConfig prefs bridge (S4d-81, accepted 2026-06-27, commit `6408a27`):** `shared/src/iosMain/.../data/repo/IosUserConfigBridge.kt` is a thin Swift-facing wrapper over the common `UserConfigRepository`: `suspend currentPreferences(): UserPreferences` is a **one-shot snapshot** (`repo.userPreferences.first()`), `suspend setOutputFormat(ImageFormat)`/`setCompressLevel(Int)`/`saveVersionCode(Int)` write through the repo, and `defaultIosUserConfigBridge()` builds over the iOS `createUserConfigDataStore()` (`NSDocumentDirectory`) store. **No `Flow`/`DataStore` in the public signatures** (only `UserPreferences`/`ImageFormat`/`Int`; `Flow`/`DataStore` are implementation/KDoc only) — Swift never collects a Kotlin `Flow`; the `suspend` setters bridge to Swift `async` (write failures surface as Swift errors, not raw Kotlin/Native crashes). **iOS runtime-proven:** `shared/src/iosTest/.../IosUserConfigBridgeTest.kt` RAN on `iosSimulatorArm64Test` — empty store defaults `(JPEG, 80)` → set `(PNG, 60)` → read back `(PNG, 60)` → `saveVersionCode(123)` ok (iOS suite 53/0). **Scope:** Kotlin-only at S4d-81 (no prefs UI, no iOS UI test, no 1:1 parity). The Swift app retention then landed in S4d-82 (below). Single-instance-per-file: a real app retains ONE bridge.
+ - **iOS Swift bridge retention (S4d-82, accepted 2026-06-27, commit `98e13d9`):** `iosApp/iosApp/WatermarkWorkflow.swift` now retains exactly one `IosUserConfigBridge` via `IosUserConfigBridgeKt.defaultIosUserConfigBridge()`, and `loadUserConfigWitness()` calls `try await userConfigBridge.currentPreferences()` **once on launch** (read-only — writes no prefs), storing the `(outputFormat/compressLevel)` snapshot or an error string in a `@Published private(set) var userConfigWitness` that is **non-visible** (not referenced from any View body). `iosApp/iosApp/ContentView.swift` triggers it via `.task { await workflow.loadUserConfigWitness() }`. This is a **link/async-interop witness only — NO prefs/settings UI, NO 1:1 parity.** **Build-proven** on the generic iOS Simulator SDK (`xcodebuild ... -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build` → `** BUILD SUCCEEDED **`; the Swift `currentPreferences(completionHandler:)` import bridges to `async throws -> UserPreferences`). The S4d-81 Kotlin bridge is unchanged; only two existing Swift files were touched (no new file, no `project.pbxproj`). Coordinator note: future prefs-UI work should replace this witness with real state usage rather than growing a parallel witness surface.
+ - **iOS watermark editor — first off-Android consumer of the shared watermark editor (S4d-102..S4d-113, accepted):** the iOS app now edits/persists watermark text, degree, tileMode, alpha, textColor, textSize, h/v gaps, textTypeface, and textStyle through the common `WaterMarkRepository` + `WatermarkConfigEditor` via the Swift-facing `IosWatermarkConfigBridge` (suspend value APIs only; no `Flow`/`DataStore` in public Swift signatures). The bridge supplies off-Android repo edges with platform-neutral iOS values: default text `"EasyWatermark 水印"`, pure `WatermarkTileMode.fromStorageId` (Android SDK-gated DECAL-id-3 migration does not apply off-Android), and simple logging. S4d-107/109/110 aligned fresh iOS renders to shared defaults for color `#FFB800`, text size `14`, and gaps `0/0`; S4d-112 added iOS Skiko render-edge honoring for `TextTypeface`; S4d-113 wired `TextPaintStyle` through the iOS edge. S4d-122 exposed the remaining commonMain paint gap, and **S4d-123 fixed it by passing `TextStyle.drawStyle` to `MultiParagraph.paint`**; `Stroke` is now raster-honored on Desktop/iOS Skiko, while Android text production remains native. This is build/runtime proof of editor state flow, NOT final Android v2.10.0 1:1 visual parity; real PHPicker grid-cell selection remains the pre-existing beta-toolchain limitation. The text-field lane is exhausted; `iconUri`/`markMode` is a separate mini-epic, and `enableBounds` stays deferred.
+ - **Desktop app-entry `UserConfigRepository` wiring (S4d-80, accepted 2026-06-27, commit `3daa7c4`):** `desktopApp` `Main.kt` now constructs `UserConfigRepository(createUserConfigDataStore(dir = File("build/s4d80-desktop-userconfig")))` and proves read → `updateFormat(PNG)` + `updateCompressLevel(60)` → read via `runBlocking { userPreferences.first() }`, verified by `:desktopApp:run` (printed initial `(JPEG, 80)` then `(PNG, 60)`; a second run showed initial `(PNG, 60)` → on-disk persistence across runs). This is the **first app-entry (non-test) use of the common prefs repo** — an **app-level smoke/witness**, NOT the real Compose Desktop editor consuming prefs (C4) and NOT 1:1 UI/UX parity. `desktopApp/build.gradle.kts` declares the existing catalog aliases `libs.kotlin.coroutine.core` + `libs.datastore.preference` (NO new dependency version) because `:desktopApp` directly consumes `:shared`'s public-API types (`Flow`, `DataStore`), which `:shared` exposes as `implementation` (so they don't transit — `:app` declares them likewise). Desktop-only: Android/`:shared`/iOS/renderer/Room/Koin/goldens untouched.
+ - **Desktop minimal product flow (S4d-119 readiness -> S4d-130, accepted 2026-06-27):** S4d-119 proved `:desktopApp` was still a CLI witness and selected the smallest non-speculative Desktop product-flow path. S4d-120 added desktop `createWaterMarkDataStore(...)`, a Desktop `WaterMarkRepository`/`WatermarkConfigEditor` roundtrip test, and a headless **open -> edit -> render -> save** flow writing `desktopApp/build/s4d120-desktop-headless/watermarked.png` (or CLI output path). S4d-121 added the minimal Compose Desktop `Window`: no-arg `:desktopApp:run` opens `EasyWatermark — Desktop (S4d-121)` and the sample button runs the same save spine; `--headless` remains the bounded automation path. **S4d-122 made Desktop text rendering and `composeOverRealImage` consume persisted `WaterMark.textColor` + `TextTypeface`** (color now defaults to shared amber `WaterMark.default.textColor`, and typeface maps to Compose `FontWeight`/`FontStyle`) and passed `TextPaintStyle` through. **S4d-123 completed shared drawStyle threading**, so `TextPaintStyle.Stroke` is raster-honored on Desktop/iOS Skiko; Android text production remains native. **S4d-124 selected the next Desktop slice, and S4d-125 added the first interactive file input:** the Desktop window now has an **"Open image..."** button using native JDK/AWT `FileDialog` to pick a real image, read bytes off the UI thread, and call the existing `DesktopWatermarkFlow.runSaveFlow(inputBytes, inputLabel)` spine. Cancel is a no-op; `--headless` stays unchanged. **S4d-126/S4d-130 split output-format safely:** S4d-127 added the Desktop encoder primitive (`ImageFormat.PNG` byte-identical `encodePng`; `ImageFormat.JPEG` ARGB -> opaque RGB flatten + JDK `ImageIO` quality), with `composeOverRealImage` still defaulting PNG for composer goldens. S4d-128 then wired the Desktop save spine to `UserConfigRepository.userPreferences.first()`: empty Desktop stores now follow shared/Android defaults (`JPEG/80`) and default output paths become format-aware (`watermarked.jpg` or `watermarked.png`), while PNG remains available when prefs select PNG. S4d-130 added the minimal window selector: two preset buttons (`JPEG / 80`, `PNG / 100`) write through the shared `OutputPrefsEditor` over the same store the save flow reads. **Still not a full Desktop editor and not 1:1 parity:** no drag/drop, interactive image preview, templates, share substitute, icon watermark, arbitrary quality selector, or final Android v2.10.0 UI parity.
+ - **Desktop product-flow continuation (S4d-132 -> S4d-143b, accepted 2026-06-27/28):** S4d-132..S4d-137 completed the Desktop icon/source mini-epic at functional-code level: icon composition primitive, persisted Image-mode save branch, "Open icon...", "Use text watermark", and remembered source-image reuse. S4d-138/S4d-140 then added the release-code map, `DesktopSaveDecision` harness, and destination-only "Save as..." control. **S4d-141..S4d-143b closed the first templates persistence lane:** Desktop has an empty-store Room builder (`buildTemplateDatabase(dir)` with `BundledSQLiteDriver`), a `:desktopApp --headless` TemplateRepository/TemplateEditor witness, and a read-only proof that both Android seed DBs match generated Room identity hash `72366f557b971b39675d0f26cbc46e0a`. **S4d-224 (accepted 2026-06-29, commit `c7959d0`) implemented the Desktop English template seed** via a `:shared` desktopMain resource + `TemplateDatabaseSeeds.unpackDefaultTemplateSeed` + `buildTemplateDatabase(dir, seedFile)` (direct seed-file copy because Room KMP off-Android does not expose `createFromFile`/`createFromAsset`). **S4d-225 (accepted 2026-06-29, commit `875b105`) made that seeding locale-aware** (`ewm-db-ch.db` for `zh` JVM locales, `ewm-db-eng.db` otherwise). **S4d-226 (accepted 2026-06-29, commit `1fa945f`) added the minimal row-level template update action** (Update button → `TemplateEditor.update`, preserving id/creationDate, enabled only for nonblank current text). **Still not a full Desktop editor and not 1:1 parity:** no full Android-equivalent editor/polish, batch, arbitrary quality selector, or final Android v2.10.0 UI parity.
+ - **Desktop window controls + share substitute + drag/drop + templates UI (S4d-145 -> S4d-160, accepted 2026-06-28):** Desktop window enrichment stayed one-file and functional: manual watermark text control, preview output, degree/color/opacity/gap/text-size/tile/typeface/style controls, scrolling, a no-new-dependency share substitute, dropped-image input, and a minimal Templates section. The share substitute tracks only the last successful real save from Render & Save / Save as / Open image / image drop; Preview temp output is deliberately not share state. Drag/drop uses Compose Desktop `dragAndDropTarget` + AWT file-list flavor and reuses the Open-image save spine. **S4d-159** chose templates UI over reactive preview because `Template.content` is just watermark text; reactive preview would reverse the deliberate manual-preview contract. **S4d-160** added Save current text / list / Use / Delete over `buildTemplateDatabase(dir)` + `TemplateRepository` + `TemplateEditor`; Use applies via `WatermarkConfigEditor.updateText`. **S4d-224 then added Desktop template default seeding** from the English Android seed DB via a `:shared` desktopMain resource + helper, **S4d-225 made it locale-aware** (`ewm-db-ch.db` for `zh` JVM locales, `ewm-db-eng.db` otherwise), and **S4d-226 added the row-level template update action** (Update button → `TemplateEditor.update`, preserving id/creationDate). **S4d-228 (accepted 2026-06-29, commit `c833aea`) then made image drag/drop multi-file:** the drop handler now watermarks and saves every supported dropped image **sequentially** (`DesktopSaveDecision.supportedImageFiles` → per-file `resolveUniqueOutputFile` → `runSaveFlow`; per-file failures continue the batch; a setup failure resets `busy`), while Open image stayed single-file and Save as single-output. **S4d-229 (accepted 2026-06-29, commit `4c895aa`) then made Open image multi-select too:** the picker uses native AWT `FileDialog.isMultipleMode = true` + `dialog.files`, filters through the same `DesktopSaveDecision.supportedImageFiles`, and saves the selection through the same sequential resolve-after-write spine (single selection = one-item batch; real modal multi-select is a manual GUI residual); Save as stays single-output. **Still not 1:1 parity:** remaining Desktop batch UX (folder batch, progress/cancel) and final Android v2.10.0 screenshot/recording parity remain separate gates.
+ - **Desktop reactive preview (S4d-198, accepted 2026-06-28, commit `1f0019b5`, `DesktopWindow.kt` only):** the blind-edit "click Preview to see it" loop is **gone** — a successful explicit editor/mode action now **auto-refreshes** the on-screen preview. One extracted local helper `refreshPreview()` drives the 6 Apply fields (text/degree/color/opacity/gaps/text-size), tile REPEAT/CLAMP, typeface, text-style, the "Use text watermark" (→Text) and "Open icon…" (→Image) mode flips, **template Use**, and the **Open image… / image drop** source changes — all through the SAME `DesktopWatermarkFlow.runSaveFlow` → `DesktopImageDecoder` temp-file spine the manual `Preview` button uses (which now just calls the helper; the duplicated inline render was removed). **Bounded** to explicit clicks / source-load events (NOT per keystroke — `onValueChange` untouched). Preview writes ONLY `build/s4d147-desktop-preview/preview.img` and **never** sets `lastSavedFile` (share-substitute stays real-save-bound); a refresh failure keeps the last good preview. "Render & Save sample" / "Save as…" remain real-save-only; Open image / drop still set `lastImage`+`lastSavedFile` from their real save, with the added refresh gated on real-save success. Functional Desktop product-flow, **not** 1:1 parity. (S4d-198b is the docs closeout.)
+ - **Desktop persistence/output/icon now use user storage, with the icon copy in a tested `shared/desktopMain` helper (`:shared:desktopTest`) (S4d-215 -> S4d-221, accepted 2026-06-28/29):** the interactive Desktop window persists its state under the stable per-user dir `~/.easywatermark` (watermark config + output prefs + templates Room DB — S4d-215), writes real saves (drop / Render & Save / Open image) to a user output dir (`~/Pictures` else `~/.easywatermark/output` — S4d-217), and on "Open icon…" copies the picked icon into app-private `~/.easywatermark/watermark_icons/icon.` persisting THAT copy path (S4d-219) — instead of repo-local `build/` dev paths or the user's original icon path. **S4d-221 (commit `1fe8ab9e`) extracted the S4d-219 copy-then-prune logic out of `DesktopWindow.kt` into a tested `shared/desktopMain` helper `DesktopIconPersistence.persistIcon(source, iconsDir)`** (verbatim semantics — same-copy no-op via `canonicalFile`, copy -> `incoming.tmp` -> atomic `Files.move(ATOMIC_MOVE, REPLACE_EXISTING)`, prune others only after the target exists -> bounded one file, throws so the prior copy survives a failed source read), now **CI-covered** by `DesktopIconPersistenceTest` (`:shared:desktopTest`, 5/0) — parity with the tested iOS `IosIconPersistence` (S4d-116). `DesktopWindow.kt` now just wires the UI to the helper (`IMAGE_EXTENSIONS` filter, mode→Image flip, S4d-198 preview refresh, and `try/catch → "Failed: …"` status unchanged). **Intentionally unchanged (do NOT "fix"):** `DesktopWatermarkFlow` build-local defaults + `Main.kt` headless/demo witness + the preview temp stay build-local by design. **S4d-222 (accepted 2026-06-29, commit `0eaafdd5`)** added collision-avoiding default save filenames: the three default-name real-save sites (image drop, Render & Save sample, Open image) now call `DesktopSaveDecision.resolveUniqueOutputFile(outputDir, fmt)` (shared `desktopMain`), producing `watermarked.` if free else the smallest `watermarked_n.` suffix; `Save as…` (user-chosen path), preview temp, headless/demo, and `DesktopWatermarkFlow` defaults stay build-local by design. Tested in `:shared:desktopTest` (`DesktopSaveDecisionTest`, 6 new cases, 11/0 total). Release-grade Desktop save safety, **not** Android v2.10.0 1:1 parity; the GUI icon-picker click stays a thin manual/UI-wiring acceptance path (the copy/prune correctness is now unit-tested).
+ - **Desktop release hygiene + packaging proof (S4d-161 -> S4d-166, accepted 2026-06-28):** S4d-161 (read-only decision) chose release-grade hygiene + a packaging proof over speculative UI widening. **S4d-162** (commit `bb5e770f`, `DesktopWindow.kt` only) made the window title release-facing (`EasyWatermark — Desktop`, dropping the `(S4d-121)` dev marker) and disabled the Templates "Save current text" button when the watermark text is blank. **S4d-163** (commit `ccc9f3f7`, `desktopApp/build.gradle.kts` only) replaced the plain Gradle `application` plugin with the Compose Desktop `compose.desktop { application { mainClass; nativeDistributions { packageName = "EasyWatermark"; packageVersion = "1.0.0" } } }` DSL, so the packaging tasks now exist (`createDistributable`/`runDistributable`/`packageDistributionForCurrentOS`) and `createDistributable` builds an **unsigned `.app` image** (native launcher + `Info.plist` + bundled JRE + app jars, under `desktopApp/build/compose/binaries/main/app/`). The `run` task moved into the Compose DSL and `:desktopApp:run --args='--headless'` still works (witnesses unchanged). **JDK caveat:** `createDistributable` **fails** under the jenv/Homebrew OpenJDK because Compose Desktop's `checkRuntime` rejects Homebrew vendors (CMP issue #3107); it **succeeds** with the already-installed Amazon Corretto 17 (`/Library/Java/JavaVirtualMachines/amazon-corretto-17.jdk/Contents/Home`). The accepted path is to use a **supported packaging JDK** — do **NOT** add `compose.desktop.packaging.checkJdkVendor=false`. **S4d-166** adds `.github/workflows/desktop_packaging.yml`, a narrow `workflow_dispatch` + path-filtered PR job using Ubuntu + Zulu 17 that runs `:desktopApp:run --args='--headless'` before `:desktopApp:createDistributable`; the green Actions run is the proof that Zulu satisfies the vendor guard. **Overclaim guard:** unsigned app-image proof/workflow ONLY — no installer formats (DMG/PKG/MSI/DEB), signing/notarization, app icon, real product-version source yet (`packageVersion = "1.0.0"` is a placeholder), macOS packaging, or Android v2.10.0 1:1 UI/UX parity.
+ - Typed `@Serializable` Navigation routes (`ui/Routes.kt`); `ImageFormat` replaces `Bitmap.CompressFormat` in the model (encode mapping at the edge, `utils/ktx/ImageFormatExt`); `AboutViewModel` is StateFlow (C1.1 started).
+ - **`WaterMarkRepository` now in commonMain (S4d-84..S4d-89, accepted 2026-06-27, final move commit `f23bf5d`):** the watermark-config DataStore consumer moved to `shared/src/commonMain/kotlin/me/rosuh/easywatermark/data/repo/WaterMarkRepository.kt` (same package/FQN) — the **second commonMain DataStore Preferences consumer** after `UserConfigRepository`. It was reached by a staged Android-edge removal (each its own slice, all behavior-preserving): **S4d-84** `Color.parseColor("#FFB800")` → const `0xFFFFB800.toInt()` (pinned by a Robolectric equality test) + `ArrayMap` → `mutableMapOf()`; **S4d-85** `java.io.IOException` → `okio.IOException`; **S4d-86** the localized default text injected as `defaultTextProvider: () -> String` (RepositoryModule passes `context.getString(R.string.config_default_water_mark_text)`, a per-emission lambda); **S4d-87** the persisted tile-id read mapper injected as `tileModeFromStorageId: (Int?) -> WatermarkTileMode` (RepositoryModule passes the **SDK-gated `toWatermarkTileMode`** in `TileModeExt.kt`, so the legacy **pre-Android-12 stored DECAL id 3 → REPEAT** behavior is preserved — NOT the pure `fromStorageId`); **S4d-88** the one diagnostic `Log.e` injected as `logError: (String) -> Unit` (RepositoryModule passes `Log.e("WaterMarkRepository", message)`, identical tag/message); **S4d-89** the byte-identical file relocation (git rename, 0 content changes). **Persisted bytes/keys unchanged** — `SP_NAME = "sp_water_mark_config"`, all `SP_KEY_*` (incl. `SP_KEY_ICON_URI`, `SP_KEY_CHANGE_LOG`), `MAX_*`/legacy constants, storage ids, and the DataStore file naming + `SharedPreferencesMigration` semantics are identical; **no DataStore migration, no golden rebaseline** (strict 49/0). `RepositoryModule` (the three injected edges) and `DataStoreModule` (still creates the Android `waterMarkDataStore` from `WaterMarkRepository.SP_NAME` via the S4d-74 `createPreferencesDataStore`) are the Android wiring edges; `TileModeExt.kt`/`EditorScreen`/the test resolve the same FQN from `:shared` unchanged. Verified by shared Desktop/iOS/iOS-sim + android + app compile, assembleDebug + assembleRelease (R8 retained the class, APK size unchanged), strict goldens 49/0. These are **code-migration slices, not UI-parity slices**.
+ - **Room/templates now in commonMain (S4d-90 readiness → S4d-91 toolchain proof → S4d-92 move, accepted + committed `8d245d9`):** the production templates Room path moved to `:shared/commonMain` at the **same FQNs** — `data/model/entity/Template`, `data/db/DateConverter`, `data/db/dao/TemplateDao`, `data/db/AppDatabase` (now `@ConstructedBy(AppDatabaseConstructor::class)` + `@Suppress("KotlinNoActualForExpect") expect object AppDatabaseConstructor : RoomDatabaseConstructor`), and `data/repo/TemplateRepository`. **S4d-90** mapped readiness (Room is atomic; prepopulated-asset data-risk). **S4d-91** proved the Room KMP toolchain (Room Gradle plugin + KSP-MP, `kspAndroid`/`kspDesktop`/`kspIosArm64`/`kspIosSimulatorArm64`) compiles/links for all 4 targets, with the proof Room/SQLite **`compileOnly` in commonMain** so the unused `libsqliteJni.so` did **not** ship to `:app` (r1 confinement; proof package since deleted). **S4d-92** did the production move. **Android driver decision = compatibility mode (no explicit `SQLiteDriver`)** — Android keeps the framework SupportSQLite open-helper + `createFromAsset`, so the prepackaged `ewm-db-ch.db`/`ewm-db-eng.db` open byte-identically and **no** `sqlite-bundled`/`libsqliteJni.so` (or extra `sqlite-framework`) ships; release APK unchanged. Android creation lives in `:shared/androidMain` `data/db/TemplateDatabaseBuilder.android.kt` `buildTemplateDatabase(context)` (locale `createFromAsset` + in-memory fallback, same `ewm-db` path); `AppModule`/`RepositoryModule` call it (`TemplateRepository` now takes an injected `ioContext: CoroutineContext` — Koin passes `Dispatchers.IO`, since `Dispatchers.IO` isn't accessible in commonMain on Native). `:shared` commonMain Room dep is `implementation(room.runtime)` (no sqlite artifact); `app/build.gradle.kts` untouched (its existing room deps cover the `RoomDatabase` supertype). `exportSchema=false`, `version=1` unchanged; no schema/migration change, no golden rebaseline. Proven by a Robolectric **prepopulated-DB smoke** (`TemplatePrepopulatedDbSmokeTest`, 2/0 — both seed assets open + read seeded templates non-empty), all-target compile, iOS link, strict goldens 51/0, debug+release APK (no `libsqliteJni.so`). **Off-Android builders now exist where there is a real consumer path:** Desktop has `buildTemplateDatabase(dir)` plus locale-aware seed packaging from S4d-224/S4d-225; **S4d-231 (accepted 2026-06-29, commit `278d585`) added the iOS empty-store builder** in `:shared/iosMain` (`BundledSQLiteDriver`, `Dispatchers.Default` query context, `NSDocumentDirectory` overload) and an iOS simulator roundtrip over `TemplateRepository`/`TemplateEditor`; **S4d-232 (accepted 2026-06-29, commit `44be9f7`) added iOS locale-aware seed packaging** by bundling byte-identical Android seed DBs into `iosApp.app`, loading them with `NSBundle`, and seed-copying to `$NSDocumentDirectory/ewm-db` before Room opens on first creation. **S4d-233 (accepted 2026-06-29, commit `86e28f8`) added the Swift-friendly `IosTemplateBridge` plus a minimal Templates UI** (Save current / Apply / Delete) over that seeded no-arg builder. **Residual:** the release APK was not run on a device (release retention is covered by R8 keep-rule analysis + the debug smoke). Code-migration slices, not UI-parity.
+ - **First shared editor use-case extracted (S4d-94 → S4d-95 → S4d-96, accepted; S4d-96 committed `7ee7e77`):** **S4d-94** (read-only) decided the **Koin common/platform split stays deferred** — no non-Android DI consumer exists (Koin is started only in `MyApp`; desktop/iOS construct their one repo manually), so a commonMain Koin module would be a framework layer with no consumer. **S4d-95** (read-only) mapped the ViewModels and found `MainViewModel` cannot move (deep `Bitmap`/`Uri`/`MediaStore`/`File`/`Context` IO) and both VMs extend Android `androidx.lifecycle.ViewModel`; the smallest non-speculative step is a commonMain **use-case** consumed by the Android VM. **S4d-96** landed it: `shared/src/commonMain/.../domain/WatermarkConfigEditor.kt` — the **first small shared editor config use-case** over the commonMain `WaterMarkRepository`, with the 12 `update*` config edits as `suspend` methods and the three inline rules moved **verbatim** (text-size `coerceAtLeast(0f)`, `WatermarkConfigRules.alphaPercentToByte`, non-empty `MediaRef` icon guard). It is a **behavior-preserving extraction, NOT a shared ViewModel or reducer**: `MainViewModel` stays Android-side, still owns `viewModelScope`/`launch` and the save/compress/gallery/picker/crash/IO flows, and constructs the use-case from the already-injected `WaterMarkRepository` (**no Koin/AppModule/Gradle/lifecycle/renderer/storage change**). Public VM method names/signatures unchanged. Verified: all-target compile + `:app`, `:shared:linkDebugTestIosSimulatorArm64`, strict goldens **51/0** (no rebaseline), debug+release APK (release size unchanged), daemon stop. **S4d-97 (accepted, committed `ec9317c`)** added the sibling `shared/commonMain/.../domain/OutputPrefsEditor.kt` — `class OutputPrefsEditor(repo: UserConfigRepository)` with one `suspend save(format, level)` wrapping `UserConfigRepository.updateFormat(format)` then `updateCompressLevel(level)` (same order, no validation/clamping added); `MainViewModel.saveOutput` keeps its signature/defaults and still owns `viewModelScope.launch` with `resetJobStatus()` after the launch/outside the coroutine, built from the already-injected `userRepo` (no DI change). Same gates green (51/0, release size unchanged). **S4d-98 (accepted, committed `d26c95b`)** added the third sibling `shared/commonMain/.../domain/TemplateEditor.kt` — `class TemplateEditor(repo: TemplateRepository)` with `isDaoNull()` (wraps `checkIfIsDaoNull()` so the VM keeps the null-DAO → `UiState.DatabaseError` mapping Android-side — `UiState` is **not** in commonMain), `suspend add(content)` constructing `Template(0, content, Clock.System.now(), Clock.System.now())` (two separate `now()` calls, verbatim) then inserting, and `suspend update/delete` delegations. `MainViewModel` keeps its public signatures + the synchronous null-DAO-check structure, owns `viewModelScope`/`launch`/`UiState`, built from the already-injected `templateRepo` (no DI change). A **cheap** commonTest (`TemplateEditorTest`, 2/0) pins `isDaoNull()` true/false (possible here because `TemplateDao` is an interface); the suspend writes are NOT directly unit-tested (commonTest has no coroutine runner) — compile/link + unchanged goldens are the build/regression net, not a unit test of those writes. The three small editor use-cases are now extracted; the remaining shared-state work (the deliberate multiplatform-`lifecycle` decision for moving `AboutViewModel`, and real Desktop/iOS editor UI consumers) stays separate.
+ - **NOT yet done** (the bulk — months of work): the broader shared ViewModel/use-case extraction remains consumer-gated — the three small shared editor use-cases landed (`WatermarkConfigEditor` S4d-96, `OutputPrefsEditor` S4d-97, `TemplateEditor` S4d-98), but `MainViewModel`/`AboutViewModel` themselves stay Android-side. **S4d-191 (read-only readiness, accepted)** confirmed there is no safe non-speculative `MainViewModel` business-IO slice right now: the consumed business logic is already shared, remaining pure-looking methods are Android `UiState` only, and the rest is Android IO/render (`ContentResolver`/`MediaStore`/`Bitmap`/`Canvas`/`Compressor`/native `WatermarkRenderer`). Do NOT create a shared ViewModel/navigation reducer/IO `expect` layer without a named real off-Android consumer or an explicit owner decision. **S4d-99 (read-only readiness, accepted)** found `AboutViewModel` is technically ready but owner-gated/speculative until a real Desktop/iOS About consumer exists; the **golden test harness (C1.7)** that gates deeper engine rewrite (C2); the Koin common/platform split (deliberately deferred by S4d-94 while no non-Android DI consumer exists); iOS Templates XCUITest/final polish remains after S4d-233 completed builder + seed packaging + Swift bridge/minimal UI; Desktop locale-aware seeding landed in S4d-225 (English default landed in S4d-224) (S4d-90..S4d-92); the full Compose Desktop editor beyond the current minimal product-flow window; Desktop renderer feature parity (Desktop reactive preview landed in S4d-198); and the remaining real PHPicker grid-cell proof. Android text/icon/composition/decode production remains native by default. KMP/CMP migration is NOT complete.
+
+Session memory (planning-with-files): `task_plan.md`, `findings.md`, `progress.md` at repo root — read them at session start, update them as you work.
+
+**Parity rule:** the visual/behavioral source of truth is the latest production release (v2.10.0, built from `master`), NOT this branch's current Compose screens. Per-layout migrations follow the 10-step skill in `.claude/skills/migrate-xml-views-to-jetpack-compose/` (screenshot baseline → migrate → visual diff → delete XML).
+
+## Commands
+
+- Build debug: `./gradlew :app:assembleDebug` → `app/build/outputs/apk/debug/`. Debug applicationId is `me.rosuh.easywatermark.debug`, so it installs alongside the production app — useful for side-by-side parity checks.
+- Unit tests: `./gradlew :app:testDebugUnitTest` (only stub tests exist today; the golden/screenshot harness is planned work — see plan C1.7). Instrumented: `./gradlew :app:connectedDebugAndroidTest`.
+- No linter is wired up (spotless/ktlint blocks in root `build.gradle.kts` are commented out). Match existing style by hand.
+- SDK constants live in `buildSrc` (`Apps.kt`): compileSdk/targetSdk 36, minSdk 23. JVM toolchain 17.
+- Release builds are minified (R8, `proguard-rules.pro` + `coroutines.pro`); CI (`.github/workflows/`) runs `:app:assembleDebug` on PRs, signing + Play upload on release.
+- Android CLI 1.0 is installed (`android`): `android docs search ''` queries an offline KB that mirrors developer.android.com AND the JetBrains KMP docs (then `android docs fetch kb://...`); `android emulator list/start`, `android screenshot`, `android layout` (UI tree as JSON — faster than screenshots for UI debugging), `android run`.
+
+## Architecture (big picture)
+
+- Modules: `:app` (everything), `:cmonet` (Material You dynamic-color gate, Android-only — **now fronted by the platform-neutral `DynamicColorCapability`** for the live Compose consumers (S4d-43, accepted); `:cmonet` is **retained** behind the Android actual, full absorption/removal is an owner-gated follow-up — ADR-0007), `:baseBenchmarks`/`:macrobenchmark` (Android-only perf), `buildSrc`.
+- **Data flow:** DataStore Preferences (watermark config via `WaterMarkRepository` and user prefs via `UserConfigRepository` — **both now commonMain** (S4d-84..S4d-89 and S4d-77 respectively), with their Android-only behavior injected at the `RepositoryModule` Koin edge; **store creation** is seamed into `:shared/androidMain` `createPreferencesDataStore(context, name)` since S4d-74, with `di/DataStoreModule.kt` keeping the `Context.userDataStore`/`waterMarkDataStore` property names) + Room (`Template` entity + `AppDatabase`/`TemplateDao`/`TemplateRepository` **now commonMain** per S4d-90..S4d-92; Android creation in `:shared/androidMain` `buildTemplateDatabase` keeps the **prepopulated DBs** `assets/ewm-db-ch.db`/`ewm-db-eng.db` selected by locale, in **Room compatibility mode** — no explicit `SQLiteDriver`, framework SupportSQLite, no `sqlite-bundled` native payload; Desktop has seeded/locale-aware `BundledSQLiteDriver` builder; iOS has a seeded `BundledSQLiteDriver` builder plus Swift bridge/minimal UI since S4d-231/S4d-233) → Koin DI (`di/`) → `MainViewModel` (large Android-side state/workflow owner; source state is now StateFlow-only after S4d-63..S4d-69; shared editor use-cases were extracted in S4d-96..S4d-98, and S4d-191 keeps the rest of this VM Android-side until a real off-Android consumer exists) → both UI stacks.
+- **Rendering engine (the product core):** `WatermarkRenderer` (Android-only seam, `:app/render`) builds a watermark "cell" offscreen (text via StaticLayout, icon via scaled bitmap, rotated), wraps it in a `BitmapShader` — `REPEAT` tiles it across the photo, `CLAMP` ("decal") draws one draggable instance at a fractional offset. Both the Compose `Canvas` preview (`EditorScreen.WaterMarkCanvas`) and export (`MainViewModel.generateImage`) call the **same** `WatermarkRenderer.build*Shader` + `compose` seam, so they composite identically. Cell sizing is **image-space** (S3a: `textPx = textSize * imageWidth / REF_WIDTH`, ref width 1000), independent of any view matrix — the old `ViewInfo`/`1/MSCALE_X` export-scale coupling was removed (S3c-1/S3c-3). The shared sizing **constants/math now live in commonMain** and are consumed by all three platforms: text image-space sizing via `WatermarkGeometry.REF_WIDTH` / `WatermarkGeometry.fontPx(textSize, imageWidth)` (Android `PainKtx.applyConfig`, Desktop, iOS — S4d-181/182), icon scale via `WatermarkCellComposer.ICON_SCALE_REFERENCE_TEXT_SIZE` (Android/Desktop/iOS — S4d-184), and cell *geometry* (gap/diagonal/rotated-AABB) via `WatermarkGeometry`. **Only the constants/math are shared — Android text/icon production raster/draw stays native** (`StaticLayout` / `buildIconShader`; ADR-0004/S4d-8/S4d-17); the `WatermarkCellComposer`/`TextRasterEnv` raster primitives remain the Desktop/iOS path, not Android-production-wired. The legacy Android renderer `REF_WIDTH` const was deleted in S4d-188 (it was orphaned; `WatermarkGeometry.REF_WIDTH` is the live source). commonMain `WatermarkCellComposer` is the **Desktop/iOS** renderer (cell raster + `composeOverBackground` composition/tiling); **Android production raster AND composition stay native by closed decisions** — icon (S4d-8), text (S4d-17), and composition (S4d-190 No-Go) — see ADR-0004.
+- **Gotchas encoded in data:** watermark config values and pure normalization rules have been moved toward commonMain without changing stored bytes. `WatermarkTileMode.storageId` still mirrors the historical Android `Shader.TileMode` ordinals; `WatermarkMode.Text/Image` mirrors the deleted `WaterMarkRepository.MarkMode` ints; `TextTypeface`/`TextPaintStyle` keep their old `serializeKey()` values; `WatermarkConfigRules` preserves the legacy clamps/conversions for text size, alpha, h/v gap, degree, and mode transitions. Android render types stay at the `:app` edge via `TileModeExt.kt` / `TextStyleExt.kt`; do not put `android.graphics.*`, `android.net.Uri`, or repo-nested types back into common model classes. **Note:** `WaterMark.iconUri` has already been migrated to the platform-neutral `MediaRef` (`@JvmInline value class`, `shared/commonMain`; needs `import kotlin.jvm.JvmInline`) with Android `Uri` only at the edge (`utils/ktx/MediaRefExt.kt`). **`ImageInfo.uri` and `WaterMarkRepository.imageInfoMap` are now `MediaRef` too — S4d-52, accepted 2026-06-25** (the `Uri ↔ MediaRef` conversion lives at the picker/share-in/gallery construction and the decode/Coil/save edges; `MediaRef` value-class string equality keeps `imageInfoMap`/`isSameItem` semantics). `ImageInfo` itself moved to `shared/commonMain` in S4d-71; the old Android range annotation on `offsetX`/`offsetY` is gone, and the normalized 0f..1f invariant is documentation only. The dead `ImageInfo.shareUri` accessor was **removed (S4d-53, 2026-06-25)** — it had zero call sites (the share-out button is an unwired empty lambda); the real export result stays in `ImageInfo.result`/`jobState`. **Deliberately kept as Android `Uri` edges (do NOT "fix" incidentally):** gallery `Image.uri`, `Action.SystemPickerImageSelected.uriList`, `SaveExportSheet.imageUris`, the picker contracts (`PickImageContract`/`MultiPickContract`), and `BitmapUtils`/`BitmapCache`/`FileUtils` decode signatures. The dead `KEY_URI`/`SP_KEY_URI` DataStore key declarations were **removed (S4d-54, 2026-06-25)** — `KEY_ICON_URI` and all real watermark prefs are untouched. **No model-layer `Uri` hygiene remains**; the only `Uri` left is the deliberate Android edges above.
+- Image IO: decode via `BitmapFactory` + inSampleSize with EXIF rotation baked in (`utils/bitmap/BitmapUtils.kt`); save via MediaStore `IS_PENDING` (API ≥29) or pre-Q file path; export deliberately strips all EXIF metadata (privacy feature — ADR-0009).
+
+## Conventions for agents
+
+- **Docs-with-code gate:** every milestone PR ships its context delta (ADR / CONTEXT.md / AGENTS.md updates) or states "no doc impact" in the PR description.
+- Do not reintroduce a `ViewInfo` / `AndroidView`-bridged renderer contract — `ViewInfo` and `WaterMarkImageView` were deleted (S3c-3); the editor preview is a Compose `Canvas` over `WatermarkRenderer`. New rendering work goes through `WatermarkRenderer` / commonMain (plan C2).
+- Do NOT route Android icon production/export through commonMain `composeIconCell` (the byte-exact Android icon draw-swap was decided against — S4d-8 Option A, ADR-0004 addendum). Android icon rendering stays on native `WatermarkRenderer.buildIconShader`; `composeIconCell` is the Desktop/iOS icon renderer. Reopening this needs an explicit owner decision (tolerance golden policy or a commonMain nearest rotated blitter), not another swap attempt.
+- Do NOT route Android text production/export through commonMain `composeTextCell` by default (S4d-17 Option C). Android text rendering stays on native `WatermarkRenderer.buildTextShader`; `composeTextCell` is the Desktop/iOS text renderer first. Reopening this needs an explicit owner decision that accepts a visible CJK perceptual tolerance and Android golden rebaseline.
+- Do NOT add a `commonMain expect`/`actual createDataStore` DataStore factory (S4d-74/S4d-78/S4d-120). DataStore store creation is **plain per-platform functions** by design: commonMain has the driver-free `createDataStore(storage)` + the okio-path `createPreferencesDataStore(producePath)` (S4d-78); Android is the plain `androidMain` `createPreferencesDataStore(context, name)` (NOT an `actual`); desktop/iOS are plain store-specific functions such as `createUserConfigDataStore(...)` and `createWaterMarkDataStore(...)`. A `commonMain expect` would force all-target actuals and is unnecessary since the platform signatures differ — keep them plain functions. Android also intentionally does NOT route through the common storage helper (byte-identical legacy preferences creation needs `PreferenceDataStoreFactory.create(produceFile, migrations)`), and Android store creation must stay byte-faithful.
+- Do NOT reintroduce direct `CMonet` usage into the **migrated Compose dynamic-color consumers** — `ComposeMainActivity` (theme gate + About-route initial state) and `AboutViewModel` (toggle write) now go through the platform-neutral `DynamicColorCapability` (S4d-43, accepted); the Android actual `AndroidDynamicColorCapability` is the only place that delegates to `:cmonet`. Intentional remaining `CMonet` sites are the Android actual, `MyApp.init` (`CMonet.init`/`applyToActivitiesIfAvailable`), and the deferred `ContextExtension`/logo path. Absorbing/removing `:cmonet` (and migrating `ContextExtension`/`MyApp.init`) is an owner-gated follow-up that needs its own proof — not an incidental change.
+- Do NOT re-add the strict pinned-environment watermark golden gate (`WATERMARK_GOLDEN_STRICT=true ./gradlew :app:testDebugUnitTest`) to GitHub PR Checks (S4d-171/S4d-172). Its baselines are Robolectric-environment FNV pixel hashes captured on a pinned host (see `WatermarkExportGoldenTest`'s policy comment + ADR-0010: "Green on JVM never proxies for Android correctness"), so they cannot pass on GitHub Ubuntu/Temurin. `.github/workflows/pr_pre_check.yml` deliberately runs only `:app:assembleDebug` + `:shared:desktopTest` + **non-strict** `:app:testDebugUnitTest` (the device-independent structural golden net); the strict FNV gate stays a LOCAL / pinned-env gate, run on the capture host or after an intentional Robolectric/Skia/font bump.
+- Decision forks get an ADR in `docs/adr/` (use the `grill-with-docs` flow); status `Proposed` until the developer signs off.
+- When unsure about an Android/KMP API, prefer `android docs search` over training data.
+- **Clean up heavy processes you start:** automated emulator sessions boot with `-no-window` when interaction isn't needed, and end with `adb emu kill` + `./gradlew --stop`; cap automated builds with `--max-workers=8`. Sustained emulator+build load has frozen this machine's input devices before — warn the developer before kicking off long heavy local automation.
+
+### Coordinator workflow loop
+
+Use this loop for non-trivial implementation work, worker coordination, milestone reviews, and release-readiness decisions:
+
+1. **Recover current truth first.** Read `AGENTS.md`, `task_plan.md`, `progress.md`, `findings.md`, relevant ADRs/docs, `git status --short --branch -uall`, and the current diff/log before planning. Treat chat history, worker summaries, and old session notes as leads, not proof.
+2. **Pick workflow tools deliberately.** Use `planning-with-files` for long-running plans, `grill-with-docs` for architecture/terminology forks, `agent-cowork-coordinator` for opencode/worker handoffs, `check` for review/commit/push readiness, and `android-cli`/AndroMeld/adb for Android verification. Keep slices small, shippable, and parity-first.
+3. **Publish worker tasks through ACSP only.** Create sessions under `~/.agent-cowork/sessions/EasyWatermark/staging/`, write `session.json`, `task.md`, `task.json`, `messages.jsonl`, `events.jsonl`, `context/brief.md`, `context/constraints.json`, and `context/references.json`, then touch `.ready` and move atomically to `inbox/`. The brief must be rich enough for weaker workers: mission, background, scope boundaries, success criteria, forbidden actions, references, verification commands, output contract, and uncertainty policy.
+4. **Notify the right worker (opencode by default).** Never rely on the focused terminal. Prefer tmux for worker routing when available: enumerate with `tmux list-panes -a -F 'session=#{session_name} window=#{window_index}:#{window_name} pane=#{pane_index} id=#{pane_id} active=#{pane_active} tty=#{pane_tty} pid=#{pane_pid} cwd=#{pane_current_path} cmd=#{pane_current_command}'`, match by worker process (`opencode` by default; legacy `claude` only if explicitly used), cwd, TTY, and EasyWatermark/session screen content, read with `tmux capture-pane -pt -S -120`, and send with `tmux send-keys -t -- '' Enter`. If the worker is in iTerm2, use the AppleScript fallback after matching the correct tab. If the worker is in a Codex side terminal without tmux, it may be readable through the terminal buffer plus process/TTY but not safely writable through Computer Use. After sending, verify the session moves from `inbox/` to `active/` or that the worker visibly starts reading it.
+5. **Start a bounded worker poll.** Immediately after handing a session to the worker, create a heartbeat automation on the current thread that checks the ACSP state and visible worker terminal for at most 30 minutes (for example, every 5 minutes, 6 checks). The poll must be read-only: no repo edits, commits, pushes, or extra worker instructions unless the current thread explicitly asks. If the session reaches `review/`, `blocked/`, `failed/`, or `done/` before the poll expires, stop treating it as active work and proceed to coordinator review/decision.
+6. **Poll by filesystem state and terminal evidence.** For ACSP sessions, inspect `inbox/`, `active/`, `review/`, `blocked/`, `failed/`, and `done/`; read `events.jsonl`, `messages.jsonl`, heartbeat/status files, and the visible worker terminal. When the worker is inside tmux, prefer `tmux capture-pane` over screenshots. Do not send extra instructions unless the current thread asks for them, the session is clearly stuck, or a review decision requires a requeue.
+7. **Review worker output as untrusted evidence.** Read `result.md`, `verification.md`, `messages.jsonl`, `events.jsonl`, and every declared artifact. Check the real diff and current files yourself. Confirm that constraints were followed, claims cite commands/files that actually exist, required outputs are present, and scope did not drift. Accept by moving the session to `done/`; otherwise write `review.md` and requeue, or leave it blocked/failed with a concrete reason.
+8. **Verify before accepting or shipping.** For code changes, inspect the diff, run the relevant Gradle gates with `--max-workers=8`, and add focused tests when fixing behavior. For renderer/UI work, use any available Android device/emulator; for UI comparison, open the production app (`me.rosuh.easywatermark`) first as baseline, then the debug app (`me.rosuh.easywatermark.debug`). Visually inspect screenshots or screen recordings instead of trusting file size, and confirm media permissions such as `READ_MEDIA_IMAGES` when entering editor flows.
+9. **Ship with `check` discipline.** Record `git rev-parse HEAD` before staging. Stage only intended files. Re-read status and HEAD before commit and before push. Do not push unless explicitly approved. After push, confirm local and remote HEAD match.
+10. **Update durable context.** After each accepted milestone, update `task_plan.md`, `progress.md`, and `findings.md`. Update `AGENTS.md`, `docs/CONTEXT.md`, or ADRs when a stable rule, architecture decision, or workflow changes. Snapshot reports are evidence, not source of truth; distill durable rules into repo docs.
+11. **Continue the loop.** End each slice with the next concrete task, its owner (coordinator or worker), required verification, and unresolved decisions. The default rhythm is: research -> plan -> publish/implement -> bounded poll -> review -> verify -> land -> update docs -> choose the next slice.
+
+## Agent skills
+
+### Issue tracker
+
+Issues and PRDs are tracked in GitHub Issues for `rosuH/EasyWatermark`. See `docs/agents/issue-tracker.md`.
+
+### Triage labels
+
+Use the canonical triage labels: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`. See `docs/agents/triage-labels.md`.
+
+### Domain docs
+
+Single-context repo: domain vocabulary lives in `docs/CONTEXT.md`, and architectural decisions live in `docs/adr/`. See `docs/agents/domain.md`.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 120000
index 00000000..47dc3e3d
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1 @@
+AGENTS.md
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 94c56eec..e6f1d4d5 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,12 +1,37 @@
import com.android.build.gradle.internal.api.ApkVariantOutputImpl
+import org.gradle.api.artifacts.component.ModuleComponentSelector
plugins {
id(libs.plugins.android.application.get().pluginId)
id(libs.plugins.kotlin.android.get().pluginId)
- id(libs.plugins.kotlin.parcelize.get().pluginId)
+ id(libs.plugins.kotlin.serialization.get().pluginId)
id(libs.plugins.ksp.get().pluginId)
- id(libs.plugins.hilt.plugin.get().pluginId)
- id(libs.plugins.spotless.get().pluginId)
+// id(libs.plugins.hilt.plugin.get().pluginId)
+ alias(libs.plugins.compose.compiler)
+// id(libs.plugins.spotless.get().pluginId)
+}
+
+// C4.3 Compose lineage unification: :shared (Compose Multiplatform) transitively brings
+// `org.jetbrains.compose.*` coordinates onto :app's Android classpath. On Android these are the same
+// classes as `androidx.compose.*` (CMP delegates to Jetpack Compose), so we substitute them to the
+// AndroidX coordinates and let the Compose BOM (2026.05.01 -> 1.11.2) pick the version. Result: a
+// single AndroidX Compose lineage on the Android runtime graph, zero `org.jetbrains.compose.*` nodes.
+// Build-config only; no source/renderer/UI behavior change.
+configurations.all {
+ resolutionStrategy.dependencySubstitution {
+ all {
+ val selector = requested
+ if (selector is ModuleComponentSelector && selector.group.startsWith("org.jetbrains.compose.")) {
+ val androidxGroup = selector.group.replaceFirst("org.jetbrains.compose", "androidx.compose")
+ // Version pinned to the Compose BOM's line (2026.05.01 -> 1.11.2). Must equal the BOM
+ // Compose version; the dependency-graph proof asserts a single 1.11.2 lineage.
+ useTarget(
+ "$androidxGroup:${selector.module}:1.11.2",
+ "C4.3: unify Compose lineage to AndroidX 1.11.2 on Android",
+ )
+ }
+ }
+ }
}
android {
@@ -16,8 +41,8 @@ android {
applicationId = "me.rosuh.easywatermark"
minSdk = (Apps.minSdk)
targetSdk = (Apps.targetSdk)
- versionCode = 21000
- versionName = "2.10.0"
+ versionCode = (Apps.versionCode)
+ versionName = (Apps.versionName)
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -46,7 +71,7 @@ android {
}
}
- packagingOptions {
+ packaging {
resources.excludes.add("DebugProbesKt.bin")
}
@@ -55,11 +80,14 @@ android {
namespace = "me.rosuh.easywatermark"
buildFeatures {
+ buildConfig = true
compose = true
}
- composeOptions {
- kotlinCompilerExtensionVersion = libs.versions.androidxComposeCompiler.get()
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ }
}
kotlin {
@@ -87,16 +115,13 @@ dependencies {
implementation(libs.datastore.preference)
// di
- implementation(libs.hilt.android)
- ksp(libs.hilt.compiler)
- androidTestImplementation(libs.hilt.testing)
- kspAndroidTest(libs.hilt.compiler)
+// implementation(libs.hilt.android)
+// ksp(libs.hilt.compiler)
+// androidTestImplementation(libs.hilt.testing)
+// kspAndroidTest(libs.hilt.compiler)
implementation(libs.asynclayout.inflater)
- implementation(libs.glide)
- ksp(libs.glide.compiler)
-
implementation(libs.compressor)
implementation(libs.kotlin.stdlib)
@@ -114,7 +139,6 @@ dependencies {
implementation(libs.recyclerview)
implementation(libs.constraintlayout)
implementation(libs.exifinterface)
- implementation(libs.palette.ktx)
implementation(libs.profileinstaller)
implementation(libs.colorpicker)
@@ -123,6 +147,7 @@ dependencies {
testImplementation(libs.test.junit)
testImplementation(libs.test.rules)
testImplementation(libs.test.runner)
+ testImplementation(libs.robolectric)
androidTestImplementation(libs.mockito.core)
androidTestImplementation(libs.mockito.android)
androidTestImplementation(libs.robolectric)
@@ -163,10 +188,9 @@ dependencies {
// implementation("com.google.accompanist:accompanist-permissions:0.33.2-alpha")
implementation(libs.accompanist.permissions)
-// implementation("io.coil-kt:coil-compose:2.3.0")
- implementation(libs.coil.kt)
+ // S4d-38: Coil 3 Compose only (io.coil-kt.coil3:coil-compose). ImageRequest comes transitively from
+ // coil-core; no coil base/View artifact, no coil-svg (SvgDecoder unused), no coil-network (local Uris).
implementation(libs.coil.kt.compose)
- implementation(libs.coil.kt.svg)
// implementation("androidx.compose.runtime:runtime-livedata:1.5.3")
implementation(libs.androidx.compose.runtime.livedata)
@@ -176,12 +200,14 @@ dependencies {
// implementation("androidx.navigation:navigation-compose:2.7.4")
implementation(libs.androidx.navigation.compose)
-
-// implementation("com.google.accompanist:accompanist-navigation-animation:0.31.1-alpha")
- implementation(libs.accompanist.navigation.animation)
+ implementation(libs.kotlinx.serialization.json)
+ implementation(project(":shared"))
// implementation("androidx.constraintlayout:constraintlayout-compose:1.0.1")
implementation(libs.androidx.constraintlayout.compose)
- implementation(libs.androidx.motionlayoout.compose)
+// implementation(libs.androidx.motionlayoout.compose)
+ implementation(project.dependencies.platform(libs.koin.bom))
+ implementation(libs.koin.core)
+ implementation(libs.koin.android)
}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 1e77ee22..7441bc49 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -1,12 +1,3 @@
--keep public class * implements com.bumptech.glide.module.GlideModule
--keep public class * extends com.bumptech.glide.module.AppGlideModule
--keep public enum com.bumptech.glide.load.ImageHeaderParser$** {
- **[] $VALUES;
- public *;
-}
-keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite {
;
}
-
--keep public class * extends com.bumptech.glide.module.AppGlideModule
--keep class com.bumptech.glide.GeneratedAppGlideModuleImpl
\ No newline at end of file
diff --git a/app/src/androidTest/assets/fonts/NotoSans-Regular.ttf b/app/src/androidTest/assets/fonts/NotoSans-Regular.ttf
new file mode 100644
index 00000000..c4687410
Binary files /dev/null and b/app/src/androidTest/assets/fonts/NotoSans-Regular.ttf differ
diff --git a/app/src/androidTest/assets/fonts/NotoSansSC-Regular.otf b/app/src/androidTest/assets/fonts/NotoSansSC-Regular.otf
new file mode 100644
index 00000000..fc0fda93
Binary files /dev/null and b/app/src/androidTest/assets/fonts/NotoSansSC-Regular.otf differ
diff --git a/app/src/androidTest/assets/fonts/OFL-NotoSans.txt b/app/src/androidTest/assets/fonts/OFL-NotoSans.txt
new file mode 100644
index 00000000..7d7fbd8f
--- /dev/null
+++ b/app/src/androidTest/assets/fonts/OFL-NotoSans.txt
@@ -0,0 +1,93 @@
+Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/app/src/androidTest/assets/fonts/OFL-NotoSansCJK.txt b/app/src/androidTest/assets/fonts/OFL-NotoSansCJK.txt
new file mode 100644
index 00000000..d952d62c
--- /dev/null
+++ b/app/src/androidTest/assets/fonts/OFL-NotoSansCJK.txt
@@ -0,0 +1,92 @@
+This Font Software is licensed under the SIL Open Font License,
+Version 1.1.
+
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font
+creation efforts of academic and linguistic communities, and to
+provide a free and open framework in which fonts may be shared and
+improved in partnership with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply to
+any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software
+components as distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to,
+deleting, or substituting -- in part or in whole -- any of the
+components of the Original Version, by changing formats or by porting
+the Font Software to a new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed,
+modify, redistribute, and sell modified and unmodified copies of the
+Font Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components, in
+Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the
+corresponding Copyright Holder. This restriction only applies to the
+primary font name as presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created using
+the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/app/src/androidTest/java/me/rosuh/easywatermark/render/WatermarkCellInstrumentedGoldenTest.kt b/app/src/androidTest/java/me/rosuh/easywatermark/render/WatermarkCellInstrumentedGoldenTest.kt
new file mode 100644
index 00000000..e6c60d83
--- /dev/null
+++ b/app/src/androidTest/java/me/rosuh/easywatermark/render/WatermarkCellInstrumentedGoldenTest.kt
@@ -0,0 +1,69 @@
+package me.rosuh.easywatermark.render
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.net.Uri
+import android.text.TextPaint
+import android.util.Log
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import me.rosuh.easywatermark.data.model.ImageInfo
+import me.rosuh.easywatermark.data.model.MediaRef
+import me.rosuh.easywatermark.data.model.WaterMark
+import me.rosuh.easywatermark.utils.ktx.applyConfig
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * INSTRUMENTED golden (CMP plan C1.7) — runs on a real device, so it faithfully renders the real
+ * watermark configs (emoji + rotation) that Robolectric NATIVE renders blank (see the JVM
+ * WatermarkCellGoldenTest "known gap"). This is the trustworthy oracle the C2a engine-wiring swap
+ * must be verified against before delegating the live renderer to commonMain WatermarkGeometry.
+ */
+@RunWith(AndroidJUnit4::class)
+class WatermarkCellInstrumentedGoldenTest {
+
+ // Sizes the output to the cell's own dimensions so the full rotated text box is captured
+ // (a fixed small window samples a transparent corner of a large emoji/rotated cell).
+ private fun renderTiledPixels(text: String, degree: Float): IntArray {
+ val config = WaterMark.default.copy(
+ text = text, degree = degree, hGap = 0, vGap = 0,
+ textSize = 24f, textColor = Color.WHITE, iconUri = MediaRef.Empty,
+ )
+ val imageInfo = ImageInfo.empty().apply { width = 1000; height = 1000 }
+ val paint = TextPaint().applyConfig(imageInfo, config, isScale = false)
+ val shader = runBlocking {
+ WatermarkRenderer.buildTextShader(
+ imageInfo, config, paint,
+ androidTextMeasureEnv(InstrumentationRegistry.getInstrumentation().targetContext),
+ Dispatchers.Unconfined,
+ )
+ }!!
+ val w = shader.width.coerceAtLeast(1)
+ val h = shader.height.coerceAtLeast(1)
+ val out = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
+ Canvas(out).drawRect(0f, 0f, w.toFloat(), h.toFloat(), Paint().apply { this.shader = shader.bitmapShader })
+ return IntArray(w * h).also { out.getPixels(it, 0, w, 0, 0, w, h) }
+ }
+
+ @Test
+ fun realDefaultConfig_emoji_rotated_renders_nonblank_on_device() {
+ val px = renderTiledPixels("👋 DO NOT REDISTRIBUTE", degree = 315f)
+ val nonTransparent = px.count { it != 0 }
+ Log.i("INSTR-GOLDEN", "emoji@315 nonTransparent=$nonTransparent / ${px.size}")
+ assertTrue("real emoji watermark @315 must render visible pixels on device", nonTransparent > 0)
+ }
+
+ @Test
+ fun asciiCell_renders_nonblank_on_device() {
+ val px = renderTiledPixels("GOLDEN", degree = 0f)
+ val nonTransparent = px.count { it != 0 }
+ Log.i("INSTR-GOLDEN", "ascii@0 nonTransparent=$nonTransparent / ${px.size}")
+ assertTrue("ascii watermark renders visible pixels on device", nonTransparent > 0)
+ }
+}
diff --git a/app/src/androidTest/java/me/rosuh/easywatermark/render/WatermarkCellParityGateTest.kt b/app/src/androidTest/java/me/rosuh/easywatermark/render/WatermarkCellParityGateTest.kt
new file mode 100644
index 00000000..67093303
--- /dev/null
+++ b/app/src/androidTest/java/me/rosuh/easywatermark/render/WatermarkCellParityGateTest.kt
@@ -0,0 +1,162 @@
+package me.rosuh.easywatermark.render
+
+import android.net.Uri
+import android.text.Layout
+import android.text.StaticLayout
+import android.text.TextPaint
+import android.util.Log
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import me.rosuh.easywatermark.data.model.ImageInfo
+import me.rosuh.easywatermark.data.model.MediaRef
+import me.rosuh.easywatermark.data.model.TextTypeface
+import me.rosuh.easywatermark.data.model.WaterMark
+import me.rosuh.easywatermark.utils.ktx.applyConfig
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import kotlin.math.max
+
+/**
+ * C2b **accepted signed-baseline** measurement gate (S3b / D1). The `TextMeasurer` measurement seam
+ * [WatermarkTextMeasurer] + [TextMeasureEnv] is now wired into the product renderer
+ * ([me.rosuh.easywatermark.render.WatermarkRenderer.buildTextShader]) for text-cell measurement
+ * (drawing stays legacy `StaticLayout`); this gate pins the same seam the product uses. Signed-baseline
+ * behavior: non-CJK strict legacy==seam (width AND height); CJK exact width + exact signed delta +
+ * exact signed absolute baseline (no tolerance widening; every row logged `PARITYGATE|…`).
+ *
+ * ACCEPTED (S3b) — the CJK baselines encode "Option 1" (Compose CJK line-height accepted as the
+ * renderer baseline), promoted from candidate per the accepted D1 sign-off (ADR-0014/ADR-0004).
+ *
+ * PLATFORM-PINNED (ADR-0010): the CJK absolute baselines below are a same-platform device baseline. Under
+ * the updated device policy (2026-06-14: any available adb target is acceptable, do not require a
+ * specific serial) they were re-pinned to the **acceptance target used for S3b verification:
+ * emulator-5554 / AVD Pixel_9_Pro_XL / Android 16 / API 36** (model `sdk_gphone64_arm64`). Robolectric
+ * is not a CJK dimension oracle; this gate is instrumented/device-only. Non-CJK rows assert parity and
+ * delta only, because their absolute glyph advances are device-font dependent under the any-device
+ * policy. Re-baseline the CJK absolute rows on the pinned CI device if the fleet changes.
+ *
+ * Device note: CJK *heights* matched the earlier SM-S906E baseline exactly (18/35/70/140); only the
+ * CJK glyph *widths* differed by a few px (wider Noto font) — recorded here, not treated as a blocker
+ * (updated policy).
+ */
+@RunWith(AndroidJUnit4::class)
+class WatermarkCellParityGateTest {
+
+ private val tag = "PARITYGATE"
+
+ /**
+ * One gate row. `seamW`/`seamH` = the CJK signed absolute baseline (API-36 device); `dH` = the
+ * signed legacy→seam height delta (0 for non-CJK). Width delta is always 0 (asserted).
+ */
+ private data class Baseline(
+ val label: String, val text: String, val typeface: Int, val size: Float,
+ val dH: Int, val seamW: Int? = null, val seamH: Int? = null,
+ )
+
+ private data class Measured(val b: Baseline, val lw: Int, val lh: Int, val sw: Int, val sh: Int)
+
+ // Non-CJK anchors — signed baseline == legacy (delta 0). The product contract is parity, not
+ // device-pinned absolute glyph advances, under the any-device policy.
+ private val nonCjk = listOf(
+ Baseline("GOLDEN@24", "GOLDEN", TextTypeface.Normal.serializeKey(), 24f, dH = 0),
+ Baseline("MULTILINE@24", "DO NOT\nREDISTRIBUTE", TextTypeface.Normal.serializeKey(), 24f, dH = 0),
+ Baseline("EMOJI@24", "👋 DO NOT REDISTRIBUTE", TextTypeface.Normal.serializeKey(), 24f, dH = 0),
+ Baseline("GOLDEN_BOLD@24", "GOLDEN", TextTypeface.Bold.serializeKey(), 24f, dH = 0),
+ Baseline("GOLDEN_ITALIC@24", "GOLDEN", TextTypeface.Italic.serializeKey(), 24f, dH = 0),
+ Baseline("GOLDEN@12", "GOLDEN", TextTypeface.Normal.serializeKey(), 12f, dH = 0),
+ Baseline("GOLDEN@48", "GOLDEN", TextTypeface.Normal.serializeKey(), 48f, dH = 0),
+ )
+
+ // CJK — signed baseline accepts the Compose line-height growth (height only; width exact vs legacy).
+ // Absolutes re-pinned to emulator-5554 / API 36. CJK HEIGHTS match the earlier SM-S906E baseline
+ // exactly (18/35/70/140); only CJK glyph WIDTHS are a few px wider (Noto). Signed ΔH unchanged
+ // (device-independent: single-line +1/+2/+5; 2-line +4/+9/+18).
+ private val cjk = listOf(
+ Baseline("CJK_MIX@12", "你好世界 watermark", TextTypeface.Normal.serializeKey(), 12f, dH = 1, seamW = 109, seamH = 18),
+ Baseline("CJK_SHORT@12", "水印", TextTypeface.Normal.serializeKey(), 12f, dH = 1, seamW = 24, seamH = 18),
+ Baseline("CJK_MULTILINE@12", "请勿转载\n仅供预览", TextTypeface.Normal.serializeKey(), 12f, dH = 4, seamW = 48, seamH = 35),
+ Baseline("CJK_MIX@24", "你好世界 watermark", TextTypeface.Normal.serializeKey(), 24f, dH = 2, seamW = 216, seamH = 35),
+ Baseline("CJK_SHORT@24", "水印", TextTypeface.Normal.serializeKey(), 24f, dH = 2, seamW = 48, seamH = 35),
+ Baseline("CJK_MULTILINE@24", "请勿转载\n仅供预览", TextTypeface.Normal.serializeKey(), 24f, dH = 9, seamW = 96, seamH = 70),
+ Baseline("CJK_MIX@48", "你好世界 watermark", TextTypeface.Normal.serializeKey(), 48f, dH = 5, seamW = 431, seamH = 70),
+ Baseline("CJK_SHORT@48", "水印", TextTypeface.Normal.serializeKey(), 48f, dH = 5, seamW = 96, seamH = 70),
+ Baseline(
+ "CJK_MULTILINE@48", "请勿转载\n仅供预览", TextTypeface.Normal.serializeKey(),
+ 48f, dH = 18, seamW = 192, seamH = 140,
+ ),
+ )
+
+ private fun paintFor(b: Baseline): TextPaint {
+ val config = WaterMark.default.copy(
+ text = b.text, textSize = b.size,
+ textTypeface = TextTypeface.obtainSealedClass(b.typeface), iconUri = MediaRef.Empty,
+ )
+ val imageInfo = ImageInfo.empty().apply { width = 1000; height = 1000 }
+ return TextPaint().applyConfig(imageInfo, config, isScale = false)
+ }
+
+ /** Legacy StaticLayout measurement used as the parity reference for product output. */
+ private fun legacyMeasure(paint: TextPaint, text: String): Pair {
+ var maxLineWidth = 0
+ text.split("\n").forEach {
+ val s = text.indexOf(it).coerceAtLeast(0)
+ maxLineWidth = max(maxLineWidth, paint.measureText(text, s, (s + it.length).coerceAtMost(text.length)).toInt())
+ }
+ val sl = StaticLayout.Builder.obtain(text, 0, text.length, paint, maxLineWidth)
+ .setAlignment(Layout.Alignment.ALIGN_NORMAL).build()
+ return sl.width to sl.height
+ }
+
+ /** Product seam — delegates to the extracted [WatermarkTextMeasurer]/[TextMeasureEnv]. */
+ private fun seamMeasure(paint: TextPaint, text: String): Pair {
+ val env = androidTextMeasureEnv(InstrumentationRegistry.getInstrumentation().targetContext)
+ val size = WatermarkTextMeasurer.measure(env, text, paint.toWatermarkTextStyle())
+ return size.width to size.height
+ }
+
+ private fun measureAndLog(b: Baseline): Measured {
+ val paint = paintFor(b)
+ val (lw, lh) = legacyMeasure(paint, b.text)
+ val (sw, sh) = seamMeasure(paint, b.text)
+ val signedBaseline = if (b.seamW != null && b.seamH != null) "${b.seamW}x${b.seamH}" else "n/a"
+ Log.i(
+ tag,
+ "PARITYGATE|${b.label}|legacy=${lw}x${lh}|seam=${sw}x${sh}|d=(${sw - lw},${sh - lh})|" +
+ "signedBaseline=$signedBaseline|signedDH=${b.dH}",
+ )
+ return Measured(b, lw, lh, sw, sh)
+ }
+
+ private fun assertRow(m: Measured, assertAbsoluteBaseline: Boolean) {
+ val b = m.b
+ // (1) width parity is ALWAYS exact — width never drifts (device-independent).
+ assertEquals("width parity [${b.label}]", m.lw, m.sw)
+ // (2) signed legacy→seam height delta is exactly the recorded value (device-independent).
+ assertEquals("signed height delta [${b.label}]", b.dH, m.sh - m.lh)
+ if (assertAbsoluteBaseline) {
+ val seamW = requireNotNull(b.seamW) { "missing signed baseline width [${b.label}]" }
+ val seamH = requireNotNull(b.seamH) { "missing signed baseline height [${b.label}]" }
+ // (3) signed absolute baseline (API-36 device) — no tolerance (device-pinned).
+ assertEquals("signed baseline width [${b.label}]", seamW, m.sw)
+ assertEquals("signed baseline height [${b.label}]", seamH, m.sh)
+ }
+ }
+
+ /** Non-CJK: seam == legacy exactly (signed delta 0) across sizes. */
+ @Test
+ fun nonCjk_cell_dimensions_match_legacy_exactly() {
+ val rows = nonCjk.map { measureAndLog(it) } // log ALL rows before asserting
+ rows.forEach { assertRow(it, assertAbsoluteBaseline = false) }
+ }
+
+ /**
+ * CJK: width exact; height equals the explicitly signed baseline/delta. Compose line-height is the
+ * accepted S3b/D1 renderer baseline. Green, not hidden.
+ */
+ @Test
+ fun cjk_cell_dimensions_match_signed_baseline() {
+ val rows = cjk.map { measureAndLog(it) } // log ALL rows before asserting
+ rows.forEach { assertRow(it, assertAbsoluteBaseline = true) }
+ }
+}
diff --git a/app/src/androidTest/java/me/rosuh/easywatermark/render/WatermarkExportInstrumentedGoldenTest.kt b/app/src/androidTest/java/me/rosuh/easywatermark/render/WatermarkExportInstrumentedGoldenTest.kt
new file mode 100644
index 00000000..3c22724a
--- /dev/null
+++ b/app/src/androidTest/java/me/rosuh/easywatermark/render/WatermarkExportInstrumentedGoldenTest.kt
@@ -0,0 +1,254 @@
+package me.rosuh.easywatermark.render
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.Shader
+import android.net.Uri
+import android.os.Build
+import android.text.TextPaint
+import android.util.Log
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import me.rosuh.easywatermark.data.model.ImageInfo
+import me.rosuh.easywatermark.data.model.MediaRef
+import me.rosuh.easywatermark.data.model.WaterMark
+import me.rosuh.easywatermark.ui.widget.utils.WaterMarkShader
+import me.rosuh.easywatermark.utils.ktx.applyConfig
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.ByteArrayOutputStream
+
+/**
+ * S0 / CMP plan C1.7 — DEVICE-AUTHORITY mirror of [WatermarkExportGoldenTest].
+ *
+ * Runs the SAME first corpus through the SAME real [WatermarkRenderer.compose] seam (REPEAT tile /
+ * CLAMP decal) on a real device, where emoji + CJK + rotation rasterize with the device's actual
+ * fonts/Skia — the values Robolectric NATIVE cannot be trusted to reproduce (ADR-0010 two-tier
+ * golden; CJK metrics are device-pinned).
+ *
+ * Two assertion tiers:
+ * - [export_corpus_renders_nonblank_on_device] / [clamp_decal_corner_is_background_on_device] /
+ * [export_encodes_jpeg_and_png_on_device] assert device-INDEPENDENT structure and run on ANY device.
+ * - [export_corpus_matches_device_pinned_baseline] (S4b) asserts the exact cell dims + cell/CLAMP
+ * FNV, but ONLY when the running device fingerprint (`MODEL/SDK`) matches a captured entry in
+ * [baselinesByDevice]; on any other device it logs the captured signature for a human to pin
+ * (so it never spuriously fails under the any-available-device policy, ADR-0014 device note).
+ *
+ * Pinned device(s): `sdk_gphone64_arm64/36` — emulator-5554 / Pixel_9_Pro_XL (AVD) / Android 16 /
+ * API 36, the S3b acceptance target, captured 2026-06-16 through the real `compose` seam. The
+ * SM-S906E (`RFCT414QBMZ`) authority pin is TBD when that device is attached. Re-baseline / add a
+ * fingerprint entry when the fleet changes; do NOT widen tolerance.
+ */
+@RunWith(AndroidJUnit4::class)
+class WatermarkExportInstrumentedGoldenTest {
+
+ private data class CellSpec(
+ val label: String,
+ val text: String? = null,
+ val iconW: Int = 0,
+ val iconH: Int = 0,
+ val degree: Float = 0f,
+ val hGap: Int = 0,
+ val vGap: Int = 0,
+ val textSize: Float = 24f,
+ )
+
+ private val corpus = listOf(
+ CellSpec("ascii_0", text = "GOLDEN", degree = 0f),
+ CellSpec("multiline", text = "LINE ONE\nLINE TWO", degree = 0f),
+ CellSpec("emoji_default_315", text = "👋 DO NOT REDISTRIBUTE", degree = 315f),
+ CellSpec("cjk", text = "水印测试", degree = 0f),
+ CellSpec("cjk_multiline_315", text = "中文\n水印", degree = 315f),
+ CellSpec("gap_h_extreme", text = "GOLDEN", degree = 0f, hGap = 300, vGap = 0),
+ CellSpec("gap_v_extreme", text = "GOLDEN", degree = 0f, hGap = 0, vGap = 300),
+ CellSpec("icon_40x20", iconW = 40, iconH = 20, degree = 0f, textSize = 14f),
+ CellSpec("icon_rot_315", iconW = 40, iconH = 20, degree = 315f, textSize = 14f),
+ )
+
+ private fun buildShader(spec: CellSpec): WaterMarkShader {
+ val imageInfo = ImageInfo.empty().apply { width = 1000; height = 1000 }
+ val config = WaterMark.default.copy(
+ text = spec.text ?: WaterMark.default.text,
+ degree = spec.degree,
+ hGap = spec.hGap,
+ vGap = spec.vGap,
+ textSize = spec.textSize,
+ textColor = Color.WHITE,
+ iconUri = MediaRef.Empty,
+ )
+ val shader = runBlocking {
+ if (spec.text != null) {
+ val paint = TextPaint().applyConfig(imageInfo, config, isScale = false)
+ WatermarkRenderer.buildTextShader(
+ imageInfo, config, paint,
+ androidTextMeasureEnv(InstrumentationRegistry.getInstrumentation().targetContext),
+ Dispatchers.Unconfined,
+ )
+ } else {
+ val src = Bitmap.createBitmap(spec.iconW, spec.iconH, Bitmap.Config.ARGB_8888).apply {
+ eraseColor(Color.WHITE)
+ Canvas(this).drawRect(0f, 0f, spec.iconW / 2f, spec.iconH.toFloat(),
+ Paint().apply { color = Color.RED })
+ }
+ WatermarkRenderer.buildIconShader(
+ imageInfo, src, config, Paint(), /* scale = */ false, Dispatchers.Unconfined,
+ )
+ }
+ }
+ assertNotNull("shader must build for '${spec.label}'", shader)
+ return shader!!
+ }
+
+ private fun renderCell(shader: WaterMarkShader): IntArray {
+ val w = shader.width.coerceAtLeast(1)
+ val h = shader.height.coerceAtLeast(1)
+ val out = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
+ Canvas(out).drawRect(0f, 0f, w.toFloat(), h.toFloat(), Paint().apply { this.shader = shader.bitmapShader })
+ return IntArray(w * h).also { out.getPixels(it, 0, w, 0, 0, w, h) }
+ }
+
+ /** S4b: composite through the REAL [WatermarkRenderer.compose] seam (see JVM twin). */
+ private fun composite(
+ imageW: Int, imageH: Int, shader: WaterMarkShader,
+ tileMode: Shader.TileMode, offsetX: Float, offsetY: Float, bg: Int,
+ ): IntArray {
+ val bmp = Bitmap.createBitmap(imageW, imageH, Bitmap.Config.ARGB_8888).apply { eraseColor(bg) }
+ WatermarkRenderer.compose(
+ canvas = Canvas(bmp),
+ shader = shader,
+ tileMode = tileMode,
+ paint = Paint(),
+ left = 0f,
+ top = 0f,
+ regionWidth = imageW.toFloat(),
+ regionHeight = imageH.toFloat(),
+ offsetX = offsetX,
+ offsetY = offsetY,
+ )
+ return IntArray(imageW * imageH).also { bmp.getPixels(it, 0, imageW, 0, 0, imageW, imageH) }
+ }
+
+ private fun fnv1a(px: IntArray): Int {
+ var h = -0x7ee3623b
+ for (p in px) { h = h xor p; h *= 0x01000193 }
+ return h
+ }
+
+ /** Device-pinned export signature for one corpus cell: cell dims + cell FNV + CLAMP-decal FNV. */
+ private data class Sig(val cellW: Int, val cellH: Int, val cellFnv: Int, val clampFnv: Int)
+
+ /** `"$MODEL/$SDK"` of the device under test (the baseline fingerprint key). */
+ private fun deviceKey(): String = "${Build.MODEL}/${Build.VERSION.SDK_INT}"
+
+ /**
+ * Device-pinned export baselines keyed by `MODEL/SDK`. Captured 2026-06-16 on
+ * emulator-5554 / Pixel_9_Pro_XL (AVD) / `sdk_gphone64_arm64` / Android 16 / API 36 through the
+ * real [WatermarkRenderer.compose] seam (`composite(256, 256, …)`, REPEAT cell + CLAMP @ 0.4/0.4).
+ * Absolutes are device-specific (Skia/font), so they are asserted only when [deviceKey] matches.
+ */
+ private val baselinesByDevice: Map> = mapOf(
+ "sdk_gphone64_arm64/36" to mapOf(
+ "ascii_0" to Sig(93, 33, -366281882, 1458584107),
+ // S4d-14C (owner-approved S4d-13 Option C): full-block vertical centring in buildTextShader
+ // (no more bottom clipping) — multiline-only device rebaseline on sdk_gphone64_arm64/36.
+ "multiline" to Sig(110, 61, 1069584506, -926274294),
+ "emoji_default_315" to Sig(228, 228, -1575206964, -978270002),
+ "cjk" to Sig(96, 35, 180443926, -355393120),
+ // S4d-14C: multiline vertical-centring fix relocates the block (dims 83x83 unchanged) —
+ // multiline-only device rebaseline on sdk_gphone64_arm64/36.
+ "cjk_multiline_315" to Sig(83, 83, -1335209525, 1264444789),
+ "gap_h_extreme" to Sig(372, 33, 182695868, -965358109),
+ "gap_v_extreme" to Sig(93, 132, 3309160, 440425003),
+ "icon_40x20" to Sig(44, 44, 2099708661, 1766162126),
+ "icon_rot_315" to Sig(44, 44, 1454838001, -1638977274),
+ ),
+ )
+
+ /**
+ * S4b: assert the captured device-pinned export signatures (cell dims + cell/CLAMP FNV) so a
+ * renderer change that drifts pixels on the SAME device/API is caught. Guarded by [deviceKey]:
+ * on an unpinned device it logs the captured signature instead of failing (any-available-device
+ * policy). Structural nonblank/decal asserts live in [export_corpus_renders_nonblank_on_device]
+ * and run everywhere.
+ */
+ @Test
+ fun export_corpus_matches_device_pinned_baseline() {
+ val key = deviceKey()
+ val pinned = baselinesByDevice[key]
+ Log.i("INSTR-EXPORT", "=== device-pinned export baselines for $key (pinned=${pinned != null}) ===")
+ for (spec in corpus) {
+ val shader = buildShader(spec)
+ val cellFnv = fnv1a(renderCell(shader))
+ val clampPx = composite(256, 256, shader, Shader.TileMode.CLAMP, 0.4f, 0.4f, Color.DKGRAY)
+ val clampFnv = fnv1a(clampPx)
+ val sig = Sig(shader.width, shader.height, cellFnv, clampFnv)
+ Log.i("INSTR-EXPORT", "BASELINE ${spec.label} -> Sig(${sig.cellW}, ${sig.cellH}, ${sig.cellFnv}, ${sig.clampFnv})")
+ val b = pinned?.get(spec.label)
+ if (b == null) {
+ Log.i("INSTR-EXPORT", "no pinned baseline for '${spec.label}' on $key — logged for human pin")
+ continue
+ }
+ assertEquals("[pinned $key] '${spec.label}' cellW", b.cellW, sig.cellW)
+ assertEquals("[pinned $key] '${spec.label}' cellH", b.cellH, sig.cellH)
+ assertEquals("[pinned $key] '${spec.label}' cellFnv", b.cellFnv, sig.cellFnv)
+ assertEquals("[pinned $key] '${spec.label}' clampFnv", b.clampFnv, sig.clampFnv)
+ }
+ }
+
+ @Test
+ fun export_corpus_renders_nonblank_on_device() {
+ Log.i("INSTR-EXPORT", "=== device export manifest (authority: RFCT414QBMZ; emulator is smoke only) ===")
+ for (spec in corpus) {
+ val shader = buildShader(spec)
+ val cellNonBlank = renderCell(shader).count { it != 0 }
+ val repeatNonBg = composite(256, 256, shader, Shader.TileMode.REPEAT, 0f, 0f, Color.DKGRAY)
+ .count { it != Color.DKGRAY }
+ val clampPx = composite(256, 256, shader, Shader.TileMode.CLAMP, 0.4f, 0.4f, Color.DKGRAY)
+ val clampNonBg = clampPx.count { it != Color.DKGRAY }
+ Log.i(
+ "INSTR-EXPORT",
+ "${spec.label} cell=${shader.width}x${shader.height} cellNonBlank=$cellNonBlank " +
+ "repeatNonBg=$repeatNonBg clampNonBg=$clampNonBg " +
+ "cellFnv=${fnv1a(renderCell(shader))} clampFnv=${fnv1a(clampPx)}",
+ )
+ assertTrue("'${spec.label}' cell has positive dims", shader.width > 0 && shader.height > 0)
+ assertTrue("'${spec.label}' cell renders visible pixels on device", cellNonBlank > 0)
+ assertTrue("'${spec.label}' REPEAT tiling paints on device", repeatNonBg > 0)
+ assertTrue("'${spec.label}' CLAMP decal paints on device", clampNonBg > 0)
+ }
+ }
+
+ @Test
+ fun clamp_decal_corner_is_background_on_device() {
+ val shader = buildShader(CellSpec("ascii", text = "GOLDEN", degree = 0f))
+ val px = composite(256, 256, shader, Shader.TileMode.CLAMP, 0.4f, 0.4f, Color.DKGRAY)
+ assertEquals("CLAMP must not tile: corner stays background", Color.DKGRAY, px[0])
+ }
+
+ @Test
+ fun export_encodes_jpeg_and_png_on_device() {
+ val shader = buildShader(CellSpec("ascii", text = "GOLDEN", degree = 0f))
+ val w = 128; val h = 128
+ val px = composite(w, h, shader, Shader.TileMode.REPEAT, 0f, 0f, Color.DKGRAY)
+ val src = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
+ src.setPixels(px, 0, w, 0, 0, w, h)
+ for (fmt in listOf(Bitmap.CompressFormat.JPEG, Bitmap.CompressFormat.PNG)) {
+ val baos = ByteArrayOutputStream()
+ assertTrue("$fmt compress", src.compress(fmt, 90, baos))
+ val bytes = baos.toByteArray()
+ assertTrue("$fmt bytes", bytes.isNotEmpty())
+ val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
+ assertNotNull("$fmt decodes", decoded)
+ assertEquals("$fmt width", w, decoded.width)
+ assertEquals("$fmt height", h, decoded.height)
+ }
+ }
+}
diff --git a/app/src/androidTest/java/me/rosuh/easywatermark/render/WatermarkTextMultilineLayoutProbeInstrumentedTest.kt b/app/src/androidTest/java/me/rosuh/easywatermark/render/WatermarkTextMultilineLayoutProbeInstrumentedTest.kt
new file mode 100644
index 00000000..347e6ac5
--- /dev/null
+++ b/app/src/androidTest/java/me/rosuh/easywatermark/render/WatermarkTextMultilineLayoutProbeInstrumentedTest.kt
@@ -0,0 +1,219 @@
+package me.rosuh.easywatermark.render
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.net.Uri
+import android.os.Build
+import android.text.Layout
+import android.text.StaticLayout
+import android.text.TextPaint
+import android.util.Log
+import androidx.compose.ui.graphics.asAndroidBitmap
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.TextMeasurer
+import androidx.compose.ui.text.style.TextAlign
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import me.rosuh.easywatermark.data.model.ImageInfo
+import me.rosuh.easywatermark.data.model.MediaRef
+import me.rosuh.easywatermark.data.model.WaterMark
+import me.rosuh.easywatermark.utils.ktx.applyConfig
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import kotlin.math.max
+
+/**
+ * S4d-11 — **multiline text layout root-cause probe (diagnostic only)**.
+ *
+ * S4d-10 found that multiline text has EXACT cell dimensions but a huge raster gap
+ * (IoU 0.11; commonMain painted 2167 opaque px vs Android 1635). This probe dumps, on-device, the
+ * Android `StaticLayout` layout model + the Compose `TextMeasurer`/`MultiParagraph` layout model for
+ * the same rows, the EXACT two centring translations each renderer uses, and the rendered ink
+ * geometry (bbox, per-band horizontal centre, bottom-edge clipping) — to localize the cause to
+ * horizontal per-line alignment vs vertical placement vs leading, grounded in source + numbers.
+ *
+ * It implements NO renderer fix and changes NO production / `:shared` code. It reconstructs both
+ * layout models in-test, exactly as `WatermarkRenderer.buildTextShader` and
+ * `WatermarkCellComposer.composeTextCell` build them, then renders the production cells for ink
+ * geometry. On-device (not Robolectric): the corpus includes CJK/rotation that Robolectric renders
+ * blank. Hard assertions are limited to nonblank + exact dims; everything else is logged (`MULTILINE-…`).
+ */
+@RunWith(AndroidJUnit4::class)
+class WatermarkTextMultilineLayoutProbeInstrumentedTest {
+
+ private val tag = "MULTILINE-ROOT"
+ private val ctx get() = InstrumentationRegistry.getInstrumentation().targetContext
+ private fun imageInfo() = ImageInfo.empty().apply { width = 1000; height = 1000 }
+ private fun dev() = "${Build.MODEL}/api${Build.VERSION.SDK_INT}"
+
+ private data class Row(val label: String, val text: String, val degree: Float)
+
+ private val corpus = listOf(
+ Row("multiline_0", "DO NOT\nREDISTRIBUTE", 0f), // primary
+ Row("ascii_0_control", "GOLDEN", 0f), // single-line control (S4d-10: byte-identical)
+ Row("multiline_315", "DO NOT\nREDISTRIBUTE", 315f),
+ Row("cjk_multiline_0", "请勿转载\n仅供预览", 0f),
+ )
+
+ private fun config(row: Row) = WaterMark.default.copy(
+ text = row.text, degree = row.degree, hGap = 0, vGap = 0,
+ textSize = 24f, textColor = Color.WHITE, iconUri = MediaRef.Empty,
+ )
+
+ // ---- pixel helpers ----------------------------------------------------------------------
+
+ private fun alphaOf(c: Int) = (c ushr 24) and 0xFF
+ private class Cell(val w: Int, val h: Int, val px: IntArray)
+
+ private fun Bitmap.toCell(): Cell {
+ val arr = IntArray(width * height); getPixels(arr, 0, width, 0, 0, width, height)
+ return Cell(width, height, arr)
+ }
+
+ private fun opaque(c: Cell) = c.px.count { alphaOf(it) > 0 }
+
+ /** alpha bounding box minX,minY,maxX,maxY (or -1s if blank). */
+ private fun bbox(c: Cell): IntArray {
+ var minX = Int.MAX_VALUE; var minY = Int.MAX_VALUE; var maxX = -1; var maxY = -1
+ for (y in 0 until c.h) for (x in 0 until c.w) if (alphaOf(c.px[y * c.w + x]) > 0) {
+ if (x < minX) minX = x; if (x > maxX) maxX = x
+ if (y < minY) minY = y; if (y > maxY) maxY = y
+ }
+ return if (maxX < 0) intArrayOf(-1, -1, -1, -1) else intArrayOf(minX, minY, maxX, maxY)
+ }
+
+ /** Opaque horizontal centre (minX..maxX midpoint) within rows [y0,y1). -1 if no ink in band. */
+ private fun bandCentreX(c: Cell, y0: Int, y1: Int): Int {
+ var minX = Int.MAX_VALUE; var maxX = -1
+ for (y in y0.coerceAtLeast(0) until y1.coerceAtMost(c.h)) for (x in 0 until c.w)
+ if (alphaOf(c.px[y * c.w + x]) > 0) { if (x < minX) minX = x; if (x > maxX) maxX = x }
+ return if (maxX < 0) -1 else (minX + maxX) / 2
+ }
+
+ /** Opaque count in the bottom edge row (clipping indicator). */
+ private fun bottomRowOpaque(c: Cell): Int {
+ var n = 0; val y = c.h - 1
+ for (x in 0 until c.w) if (alphaOf(c.px[y * c.w + x]) > 0) n++
+ return n
+ }
+
+ // ---- production cell renderers (exactly as the two seams build them) --------------------
+
+ private fun androidCell(row: Row): Cell {
+ val info = imageInfo(); val cfg = config(row)
+ val paint = TextPaint().applyConfig(info, cfg, isScale = false)
+ val shader = runBlocking {
+ WatermarkRenderer.buildTextShader(info, cfg, paint, androidTextMeasureEnv(ctx), Dispatchers.Unconfined)
+ }!!
+ val w = shader.width.coerceAtLeast(1); val h = shader.height.coerceAtLeast(1)
+ val out = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
+ Canvas(out).drawRect(0f, 0f, w.toFloat(), h.toFloat(), Paint().apply { this.shader = shader.bitmapShader })
+ return out.toCell()
+ }
+
+ private fun commonCell(row: Row): Cell {
+ val info = imageInfo(); val cfg = config(row)
+ val paint = TextPaint().applyConfig(info, cfg, isScale = false)
+ val env = androidTextMeasureEnv(ctx)
+ val rasterEnv = TextRasterEnv(env.fontFamilyResolver, env.density, env.layoutDirection)
+ val content = WatermarkTextContent(row.text, paint.toWatermarkTextStyle(), androidx.compose.ui.graphics.Color.White)
+ return WatermarkCellComposer.composeTextCell(
+ rasterEnv, content, degree = cfg.degree, hGapPercent = cfg.hGap, vGapPercent = cfg.vGap,
+ ).asAndroidBitmap().toCell()
+ }
+
+ // ---- layout-model reconstruction (mirrors each seam) ------------------------------------
+
+ /** Rebuild Android StaticLayout exactly as buildTextShader does, and log its line metrics. */
+ private fun dumpAndroidLayout(row: Row, finalW: Int, finalH: Int) {
+ val info = imageInfo(); val cfg = config(row)
+ val paint = TextPaint().applyConfig(info, cfg, isScale = false)
+ var maxLineWidth = 0
+ cfg.text.split("\n").forEach {
+ val s = cfg.text.indexOf(it).coerceAtLeast(0)
+ maxLineWidth = max(maxLineWidth, paint.measureText(cfg.text, s, (s + it.length).coerceAtMost(cfg.text.length)).toInt())
+ }
+ val sl = StaticLayout.Builder.obtain(cfg.text, 0, cfg.text.length, paint, maxLineWidth)
+ .setAlignment(Layout.Alignment.ALIGN_NORMAL).build()
+ // The EXACT vertical translate buildTextShader uses + horizontal origin. S4d-14C: the renderer
+ // now centres the FULL block `(finalH - sl.height)/2` (was line-0-based); this log mirrors that.
+ val yOffset = (finalH - sl.height) / 2f
+ val xOrigin = finalW / 2f
+ Log.i(tag, "MULTILINE-ANDROID|label=${row.label}|paintAlign=${paint.textAlign}|maxLineWidth=$maxLineWidth" +
+ "|slW=${sl.width}|slH=${sl.height}|lineCount=${sl.lineCount}|xOrigin=$xOrigin|yOffset=$yOffset")
+ for (i in 0 until sl.lineCount) {
+ Log.i(tag, "MULTILINE-ANDROID-LINE|label=${row.label}|i=$i|top=${sl.getLineTop(i)}|bottom=${sl.getLineBottom(i)}" +
+ "|baseline=${sl.getLineBaseline(i)}|ascent=${sl.getLineAscent(i)}|descent=${sl.getLineDescent(i)}" +
+ "|left=${sl.getLineLeft(i)}|right=${sl.getLineRight(i)}|width=${sl.getLineWidth(i)}")
+ }
+ }
+
+ /** Rebuild the Compose TextMeasurer layout exactly as composeTextCell does, and log line metrics. */
+ private fun dumpComposeLayout(row: Row, finalW: Int, finalH: Int) {
+ val cfg = config(row)
+ val info = imageInfo()
+ val paint = TextPaint().applyConfig(info, cfg, isScale = false)
+ val env = androidTextMeasureEnv(ctx)
+ // S4d-12: mirror composeTextCell's measurement (TextAlign.Center ONLY when lineCount > 1) so
+ // this dump matches the fixed cell.
+ val measurer = TextMeasurer(env.fontFamilyResolver, env.density, env.layoutDirection)
+ val base = paint.toWatermarkTextStyle()
+ val initial = measurer.measure(AnnotatedString(row.text), style = base)
+ val layout = if (initial.lineCount > 1) {
+ measurer.measure(AnnotatedString(row.text), style = base.copy(textAlign = TextAlign.Center))
+ } else {
+ initial
+ }
+ val textW = layout.size.width.toFloat().coerceAtLeast(1f)
+ val textH = layout.size.height.toFloat().coerceAtLeast(1f)
+ // The EXACT box-centring translate composeTextCell uses.
+ val xOffset = (finalW - textW) / 2f
+ val yOffset = (finalH - textH) / 2f
+ Log.i(tag, "MULTILINE-COMPOSE|label=${row.label}|sizeW=${layout.size.width}|sizeH=${layout.size.height}" +
+ "|lineCount=${layout.lineCount}|firstBaseline=${layout.firstBaseline}|lastBaseline=${layout.lastBaseline}" +
+ "|xOffset=$xOffset|yOffset=$yOffset")
+ for (i in 0 until layout.lineCount) {
+ Log.i(tag, "MULTILINE-COMPOSE-LINE|label=${row.label}|i=$i|top=${layout.getLineTop(i)}|bottom=${layout.getLineBottom(i)}" +
+ "|left=${layout.getLineLeft(i)}|right=${layout.getLineRight(i)}")
+ }
+ }
+
+ // ---- the probe --------------------------------------------------------------------------
+
+ @Test
+ fun multiline_layout_root_cause_probe() {
+ val d = dev()
+ Log.i(tag, "=== S4d-11 multiline layout root-cause probe on $d (diagnostic; thresholds NOT asserted) ===")
+ val failures = mutableListOf()
+
+ for (row in corpus) {
+ val a = androidCell(row); val c = commonCell(row)
+ val sameDims = a.w == c.w && a.h == c.h
+ val ab = bbox(a); val cb = bbox(c)
+ // For multiline, compare the top half vs bottom half horizontal centres to expose per-line
+ // alignment (Android centres each line; commonMain left-aligns each line in the box).
+ val aTopCx = bandCentreX(a, 0, a.h / 2); val aBotCx = bandCentreX(a, a.h / 2, a.h)
+ val cTopCx = bandCentreX(c, 0, c.h / 2); val cBotCx = bandCentreX(c, c.h / 2, c.h)
+
+ Log.i(tag, "MULTILINE-ROOT|label=${row.label}|device=$d|android=${a.w}x${a.h}|common=${c.w}x${c.h}" +
+ "|androidOpaque=${opaque(a)}|commonOpaque=${opaque(c)}" +
+ "|androidBBox=${ab.joinToString(",")}|commonBBox=${cb.joinToString(",")}" +
+ "|androidBandCx(top/bot)=$aTopCx/$aBotCx|commonBandCx(top/bot)=$cTopCx/$cBotCx" +
+ "|androidBottomRowOpaque=${bottomRowOpaque(a)}|commonBottomRowOpaque=${bottomRowOpaque(c)}")
+
+ // Layout-model dumps reuse the production finalW/finalH (cells share dims; use android's).
+ dumpAndroidLayout(row, a.w, a.h)
+ dumpComposeLayout(row, a.w, a.h)
+
+ if (opaque(a) <= 0) failures += "${row.label}: android blank"
+ if (opaque(c) <= 0) failures += "${row.label}: common blank"
+ if (!sameDims) failures += "${row.label}: dims differ ${a.w}x${a.h} vs ${c.w}x${c.h}"
+ }
+ assertTrue("probe invariant failures: $failures", failures.isEmpty())
+ }
+}
diff --git a/app/src/androidTest/java/me/rosuh/easywatermark/render/WatermarkTextRasterParityInstrumentedTest.kt b/app/src/androidTest/java/me/rosuh/easywatermark/render/WatermarkTextRasterParityInstrumentedTest.kt
new file mode 100644
index 00000000..5d52ff9d
--- /dev/null
+++ b/app/src/androidTest/java/me/rosuh/easywatermark/render/WatermarkTextRasterParityInstrumentedTest.kt
@@ -0,0 +1,364 @@
+package me.rosuh.easywatermark.render
+
+import android.content.res.AssetManager
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.Typeface
+import android.net.Uri
+import android.os.Build
+import android.text.TextPaint
+import android.util.Log
+import androidx.compose.ui.graphics.asAndroidBitmap
+import androidx.compose.ui.text.font.Font
+import androidx.compose.ui.text.font.FontFamily
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import me.rosuh.easywatermark.data.model.ImageInfo
+import me.rosuh.easywatermark.data.model.MediaRef
+import me.rosuh.easywatermark.data.model.TextTypeface
+import me.rosuh.easywatermark.data.model.WaterMark
+import me.rosuh.easywatermark.utils.ktx.applyConfig
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import kotlin.math.abs
+
+/**
+ * S4d-10 — **on-device text RASTER delta gate (measure-only)**.
+ *
+ * Renders each watermark text corpus row two ways on a real device and quantifies the difference
+ * between the Android production text raster and the commonMain text raster:
+ * - **Android production:** [WatermarkRenderer.buildTextShader] (`StaticLayout.draw`), one cell drawn
+ * from its `BitmapShader` at the origin (the existing cell-capture idiom);
+ * - **commonMain:** [WatermarkCellComposer.composeTextCell] (`MultiParagraph.paint`), via the SAME
+ * system font + SAME measurement env ([androidTextMeasureEnv] → [TextRasterEnv]) and the SAME
+ * [TextPaint]-derived style ([toWatermarkTextStyle]).
+ *
+ * Why on-device, not JVM: Robolectric NATIVE renders emoji/rotated text BLANK and its CJK metrics
+ * differ from a device (ADR-0010 / `WatermarkCellGoldenTest`), so a JVM run cannot be the text raster
+ * oracle. This MUST run instrumented.
+ *
+ * Scope (S4d-9 accepted plan): this is a MEASUREMENT slice, NOT a renderer swap. It touches no
+ * production / `:shared` / build / font / golden / UI code. Hard assertions are limited to **exact cell
+ * dimensions** (S3b already proves measurement parity) and **non-blank output** on both paths. It does
+ * NOT assert byte-equality, IoU, alpha-MAE, or colour-diff thresholds — those numbers are logged
+ * (`TEXT-RASTER|…`) for the coordinator to decide whether an Android text draw-swap is viable as-is,
+ * needs a bundled font + sign-off, or should stay native.
+ */
+@RunWith(AndroidJUnit4::class)
+class WatermarkTextRasterParityInstrumentedTest {
+
+ private val tag = "TEXT-RASTER"
+ private val ctx get() = InstrumentationRegistry.getInstrumentation().targetContext
+ private fun imageInfo() = ImageInfo.empty().apply { width = 1000; height = 1000 }
+ private fun deviceKey() = "${Build.MODEL}/api${Build.VERSION.SDK_INT}"
+
+ private data class Row(
+ val label: String,
+ val text: String,
+ val degree: Float,
+ val typeface: TextTypeface = TextTypeface.Normal,
+ )
+
+ private val corpus = listOf(
+ Row("ascii_0", "GOLDEN", 0f),
+ Row("ascii_315", "GOLDEN", 315f),
+ Row("multiline", "DO NOT\nREDISTRIBUTE", 0f),
+ Row("emoji_default_315", "👋 DO NOT REDISTRIBUTE", 315f),
+ Row("cjk_0", "你好世界", 0f),
+ Row("cjk_315", "你好世界", 315f),
+ Row("bold", "GOLDEN", 0f, TextTypeface.Bold),
+ Row("italic", "GOLDEN", 0f, TextTypeface.Italic),
+ // S4d-15: complete the gap map for the bundled-font/parity-threshold decision.
+ Row("bold_italic", "GOLDEN", 0f, TextTypeface.BoldItalic),
+ Row("cjk_multiline_0", "请勿转载\n仅供预览", 0f),
+ )
+
+ // ---- pixel helpers (mirrors WatermarkRendererCommonParityTest) --------------------------
+
+ private fun alphaOf(c: Int): Int = (c ushr 24) and 0xFF
+ private data class Cell(val w: Int, val h: Int, val px: IntArray)
+
+ private fun Bitmap.toCell(): Cell {
+ val arr = IntArray(width * height)
+ getPixels(arr, 0, width, 0, 0, width, height)
+ return Cell(width, height, arr)
+ }
+
+ private fun nonTransparent(c: Cell): Int = c.px.count { alphaOf(it) > 0 }
+
+ /** IoU of opaque-pixel footprints; requires equal dims. */
+ private fun opaqueIoU(a: Cell, b: Cell): Double {
+ var inter = 0; var union = 0
+ for (i in a.px.indices) {
+ val oa = alphaOf(a.px[i]) > 0; val ob = alphaOf(b.px[i]) > 0
+ if (oa || ob) union++
+ if (oa && ob) inter++
+ }
+ return if (union == 0) 1.0 else inter.toDouble() / union
+ }
+
+ /** (mean-abs-alpha-delta, max-abs-alpha-delta); requires equal dims. */
+ private fun alphaDelta(a: Cell, b: Cell): Pair {
+ var sum = 0L; var max = 0
+ for (i in a.px.indices) {
+ val d = abs(alphaOf(a.px[i]) - alphaOf(b.px[i]))
+ sum += d; if (d > max) max = d
+ }
+ return (sum.toDouble() / a.px.size) to max
+ }
+
+ private fun colorDiff(a: Cell, b: Cell): Int {
+ var n = 0
+ for (i in a.px.indices) if (a.px[i] != b.px[i]) n++
+ return n
+ }
+
+ // ---- cell builders ----------------------------------------------------------------------
+
+ private fun config(row: Row) = WaterMark.default.copy(
+ text = row.text,
+ degree = row.degree,
+ hGap = 0,
+ vGap = 0,
+ textSize = 24f,
+ textColor = Color.WHITE,
+ textTypeface = row.typeface,
+ iconUri = MediaRef.Empty,
+ )
+
+ /** Android production text cell = one tile of its BitmapShader rendered at the origin. */
+ private fun androidCell(row: Row): Cell {
+ val info = imageInfo()
+ val cfg = config(row)
+ val paint = TextPaint().applyConfig(info, cfg, isScale = false)
+ val shader = runBlocking {
+ WatermarkRenderer.buildTextShader(info, cfg, paint, androidTextMeasureEnv(ctx), Dispatchers.Unconfined)
+ }!!
+ val w = shader.width.coerceAtLeast(1); val h = shader.height.coerceAtLeast(1)
+ val out = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
+ Canvas(out).drawRect(0f, 0f, w.toFloat(), h.toFloat(), Paint().apply { this.shader = shader.bitmapShader })
+ return out.toCell()
+ }
+
+ /** commonMain text cell via composeTextCell, with the SAME font/measurement env + style. */
+ private fun commonCell(row: Row): Cell {
+ val info = imageInfo()
+ val cfg = config(row)
+ val paint = TextPaint().applyConfig(info, cfg, isScale = false)
+ val env = androidTextMeasureEnv(ctx)
+ val rasterEnv = TextRasterEnv(env.fontFamilyResolver, env.density, env.layoutDirection)
+ val content = WatermarkTextContent(
+ text = row.text,
+ style = paint.toWatermarkTextStyle(),
+ color = androidx.compose.ui.graphics.Color.White,
+ )
+ val cell = WatermarkCellComposer.composeTextCell(
+ rasterEnv, content, degree = cfg.degree, hGapPercent = cfg.hGap, vGapPercent = cfg.vGap,
+ )
+ return cell.asAndroidBitmap().toCell()
+ }
+
+ /**
+ * S4d-16 (owner-approved C2, test-only): the bundled Latin + CJK SC [FontFamily], loaded from the
+ * androidTest assets (`app/src/androidTest/assets/fonts/`) via the test apk's `AssetManager` — NOT
+ * production res/assets, NOT compose-resources. Injected through `WatermarkTextContent.style.fontFamily`
+ * across the existing `TextRasterEnv` boundary. Bold/Italic synthesized (no bundled bold/italic faces).
+ */
+ private fun testAssets(): AssetManager = InstrumentationRegistry.getInstrumentation().context.assets
+
+ /**
+ * S4d-16 round 2 (P1): the bundled Latin + CJK SC [FontFamily] for the commonMain side. [latinFirst]
+ * lists the Latin face first (owner's Latin+CJK order) or the CJK face first (round-1 order). NOTE a
+ * Compose `FontFamily(fontA, fontB)` of the same weight/style does not guarantee per-glyph fallback
+ * between the two user fonts — the test logs BOTH orders so the real behaviour is visible.
+ */
+ private fun bundledFamily(latinFirst: Boolean): FontFamily {
+ val latin = Font(assetManager = testAssets(), path = "fonts/NotoSans-Regular.ttf")
+ val cjk = Font(assetManager = testAssets(), path = "fonts/NotoSansSC-Regular.otf")
+ return if (latinFirst) FontFamily(latin, cjk) else FontFamily(cjk, latin)
+ }
+
+ /**
+ * S4d-16 round 2 (P1): a real Android [Typeface] from the SAME bundled fonts with a proper per-glyph
+ * fallback chain ([Typeface.CustomFallbackBuilder], API 29+): Latin primary + CJK fallback (or
+ * reversed). This is what gives the test-only Android `StaticLayout` comparator the same bundled-font
+ * strategy as the commonMain side, so the two can be compared at matched dims to isolate the
+ * `StaticLayout`-vs-`MultiParagraph` ENGINE delta (not confounded by font choice). Test-only.
+ */
+ private fun bundledTypeface(latinFirst: Boolean): Typeface {
+ val am = testAssets()
+ val latinFam = android.graphics.fonts.FontFamily.Builder(
+ android.graphics.fonts.Font.Builder(am, "fonts/NotoSans-Regular.ttf").build(),
+ ).build()
+ val cjkFam = android.graphics.fonts.FontFamily.Builder(
+ android.graphics.fonts.Font.Builder(am, "fonts/NotoSansSC-Regular.otf").build(),
+ ).build()
+ val primary = if (latinFirst) latinFam else cjkFam
+ val fallback = if (latinFirst) cjkFam else latinFam
+ return Typeface.CustomFallbackBuilder(primary).addCustomFallback(fallback).build()
+ }
+
+ /** commonMain text cell rendered with the BUNDLED font (S4d-16), [latinFirst] order configurable. */
+ private fun commonCellBundled(row: Row, latinFirst: Boolean): Cell {
+ val info = imageInfo()
+ val cfg = config(row)
+ val paint = TextPaint().applyConfig(info, cfg, isScale = false)
+ val env = androidTextMeasureEnv(ctx)
+ val rasterEnv = TextRasterEnv(env.fontFamilyResolver, env.density, env.layoutDirection)
+ val content = WatermarkTextContent(
+ text = row.text,
+ style = paint.toWatermarkTextStyle().copy(fontFamily = bundledFamily(latinFirst)),
+ color = androidx.compose.ui.graphics.Color.White,
+ )
+ return WatermarkCellComposer.composeTextCell(
+ rasterEnv, content, degree = cfg.degree, hGapPercent = cfg.hGap, vGapPercent = cfg.vGap,
+ ).asAndroidBitmap().toCell()
+ }
+
+ /**
+ * S4d-16 round 2 (P1): TEST-ONLY Android `StaticLayout` comparator using the SAME bundled fonts
+ * ([bundledTypeface]) — replicates the production text path (legacy `StaticLayout` + S4d-14C full-block
+ * vertical centring + CENTER `TextPaint`) but with the bundled Typeface, drawn into a cell of the
+ * GIVEN [w]x[h] (the commonMain bundled cell's dims) so the two share dims and the comparison isolates
+ * the layout-engine raster delta. Does NOT touch production `WatermarkRenderer`. Requires API 29+
+ * (for the custom-fallback Typeface); the caller guards on SDK_INT.
+ */
+ private fun androidBundledCell(row: Row, latinFirst: Boolean, w: Int, h: Int): Cell {
+ val info = imageInfo()
+ val cfg = config(row)
+ // Preserve the requested TextTypeface style on the bundled custom-fallback typeface, mirroring
+ // production `Typeface.create(typeface, config.textTypeface.obtainSysTypeface())` (PainKtx.kt) and
+ // the common bundled side's `toWatermarkTextStyle()` weight/style. Without this the bold/italic
+ // rows would compare styled commonMain vs NORMAL Android, confounding the engine finding.
+ // `Typeface.create(family, style)` derives from the family, keeping its custom fallback chain.
+ val styledBundled = Typeface.create(bundledTypeface(latinFirst), cfg.textTypeface.obtainSysTypeface())
+ val paint = TextPaint().applyConfig(info, cfg, isScale = false).apply { typeface = styledBundled }
+ var maxLineWidth = 0
+ cfg.text.split("\n").forEach {
+ val s = cfg.text.indexOf(it).coerceAtLeast(0)
+ maxLineWidth = maxOf(maxLineWidth, paint.measureText(cfg.text, s, (s + it.length).coerceAtMost(cfg.text.length)).toInt())
+ }
+ val sl = android.text.StaticLayout.Builder
+ .obtain(cfg.text, 0, cfg.text.length, paint, maxLineWidth)
+ .setAlignment(android.text.Layout.Alignment.ALIGN_NORMAL)
+ .build()
+ val cw = w.coerceAtLeast(1); val ch = h.coerceAtLeast(1)
+ val out = Bitmap.createBitmap(cw, ch, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(out)
+ canvas.rotate(cfg.degree, cw / 2f, ch / 2f)
+ canvas.save()
+ canvas.translate(cw / 2f, (ch - sl.height) / 2f) // S4d-14C full-block centring
+ sl.draw(canvas)
+ canvas.restore()
+ return out.toCell()
+ }
+
+ // ---- the gate ---------------------------------------------------------------------------
+
+ @Test
+ fun text_raster_delta_android_vs_commonMain() {
+ val dev = deviceKey()
+ Log.i(tag, "=== S4d-10 text raster delta gate on $dev (measure-only; thresholds NOT asserted) ===")
+ val failures = mutableListOf()
+
+ for (row in corpus) {
+ val a = androidCell(row)
+ val c = commonCell(row)
+ val aNon = nonTransparent(a); val cNon = nonTransparent(c)
+ val sameDims = a.w == c.w && a.h == c.h
+ val iou = if (sameDims) opaqueIoU(a, c) else -1.0
+ val (mae, maxD) = if (sameDims) alphaDelta(a, c) else (-1.0 to -1)
+ val cdiff = if (sameDims) colorDiff(a, c) else -1
+ val total = a.w * a.h
+
+ // Full metrics line — logged for EVERY row before any assertion, so logcat captures the
+ // complete table even if a row fails the hard dims/nonblank checks.
+ Log.i(
+ tag,
+ "TEXT-RASTER|label=${row.label}|device=$dev|android=${a.w}x${a.h}|common=${c.w}x${c.h}" +
+ "|androidNonBlank=$aNon|commonNonBlank=$cNon|iou=$iou|alphaMae=$mae" +
+ "|maxAlphaDelta=$maxD|colorDiff=$cdiff/$total",
+ )
+
+ if (aNon <= 0) failures += "${row.label}: android cell blank"
+ if (cNon <= 0) failures += "${row.label}: common cell blank"
+ if (!sameDims) failures += "${row.label}: dims differ android=${a.w}x${a.h} common=${c.w}x${c.h}"
+ }
+
+ // Hard assertions for THIS slice only: every row nonblank on both paths + exact dims.
+ assertTrue("text raster gate failures: $failures", failures.isEmpty())
+ }
+
+ /**
+ * S4d-16 bundled-font re-measurement (measure-only). For each corpus row, renders the commonMain
+ * cell with the BUNDLED Noto Sans SC font and logs two deltas:
+ * - `bundledVsAndroid` — bundled commonMain vs the production Android `StaticLayout` (system font):
+ * the delta a FUTURE bundled-font text draw-swap would have vs current production.
+ * - `bundledVsCommonSys` — bundled vs system-font commonMain: whether bundling changes the common
+ * side at all on-device (on a device the system CJK font is already Noto CJK, so for CJK this is
+ * expected to be near-zero — confirming S4d-15's finding that the Android CJK gap is the
+ * `StaticLayout`-vs-`MultiParagraph` ENGINE, not the font).
+ * IoU/colorDiff are computed only when dims match; the bundled font may measure a different cell box
+ * than the system font (Noto Sans SC metrics ≠ system), which is LOGGED, not gated. Hard assertion:
+ * the bundled cell renders non-blank. No threshold gating; no production wiring; CJK stays LOG-ONLY.
+ */
+ @Test
+ fun text_raster_delta_bundled_font_S4d16() {
+ val dev = deviceKey()
+ val canBundleAndroid = Build.VERSION.SDK_INT >= 29 // Typeface.CustomFallbackBuilder
+ Log.i(tag, "=== S4d-16 r2 bundled-font text raster delta on $dev (measure-only; androidBundled=$canBundleAndroid) ===")
+ val failures = mutableListOf()
+ for (row in corpus) {
+ val aSys = androidCell(row) // production: StaticLayout + system font
+ val cSys = commonCell(row) // commonMain MultiParagraph + system font
+ val cLF = commonCellBundled(row, latinFirst = true) // commonMain + bundled (Latin-first)
+ val cCF = commonCellBundled(row, latinFirst = false) // commonMain + bundled (CJK-first, r1 order)
+
+ // P1a — does Latin-first restore ~system dims, and does CJK still render? (fallback behaviour)
+ Log.i(
+ tag,
+ "TEXT-RASTER-BUNDLED-ORDER|label=${row.label}|device=$dev|system=${aSys.w}x${aSys.h}" +
+ "|commonLatinFirst=${cLF.w}x${cLF.h}|nbLatinFirst=${nonTransparent(cLF)}" +
+ "|commonCjkFirst=${cCF.w}x${cCF.h}|nbCjkFirst=${nonTransparent(cCF)}",
+ )
+
+ // P1b — engine delta at MATCHED dims: commonBundledLatinFirst vs androidBundledLatinFirst
+ // (same bundled fonts both sides, android via StaticLayout + custom-fallback Typeface).
+ if (canBundleAndroid) {
+ val aLF = androidBundledCell(row, latinFirst = true, w = cLF.w, h = cLF.h)
+ // Engine delta: cLF vs aLF — both intentionally at cLF dims, so the denominator is the
+ // compared pair's area (NOT the system cell's area). aLF is built at cLF.w x cLF.h.
+ val dimsEng = cLF.w == aLF.w && cLF.h == aLF.h
+ val engTotal = cLF.w * cLF.h
+ val iouEngine = if (dimsEng) opaqueIoU(cLF, aLF) else -1.0
+ val cdiffEngine = if (dimsEng) "${colorDiff(cLF, aLF)}/$engTotal" else "na"
+ // Swap delta: commonBundledLatinFirst vs existing production/system Android cell. The
+ // bundled font shifts dims off the system cell, so this is usually dims-mismatched →
+ // log an explicit `na` denominator rather than a misleading area.
+ val dimsSwap = cLF.w == aSys.w && cLF.h == aSys.h
+ val iouSwap = if (dimsSwap) opaqueIoU(cLF, aSys) else -1.0
+ val cdiffSwap = if (dimsSwap) "${colorDiff(cLF, aSys)}/${aSys.w * aSys.h}" else "na"
+ Log.i(
+ tag,
+ "TEXT-RASTER-BUNDLED|label=${row.label}|device=$dev|commonLF=${cLF.w}x${cLF.h}" +
+ "|androidLF=${aLF.w}x${aLF.h}|androidSys=${aSys.w}x${aSys.h}" +
+ "|androidBundledNonBlank=${nonTransparent(aLF)}" +
+ "|iou_commonLFvsAndroidLF=$iouEngine|colorDiff_commonLFvsAndroidLF=$cdiffEngine" +
+ "|iou_commonLFvsAndroidSys=$iouSwap|colorDiff_commonLFvsAndroidSys=$cdiffSwap",
+ )
+ if (nonTransparent(aLF) <= 0) failures += "${row.label}: android bundled cell blank"
+ } else {
+ Log.i(tag, "TEXT-RASTER-BUNDLED|label=${row.label}|device=$dev|androidBundled=SKIPPED(api<29)")
+ }
+
+ if (nonTransparent(cLF) <= 0) failures += "${row.label}: common bundled (latin-first) cell blank"
+ if (nonTransparent(cCF) <= 0) failures += "${row.label}: common bundled (cjk-first) cell blank"
+ }
+ assertTrue("bundled-font measurement failures: $failures", failures.isEmpty())
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 9d40be7e..547f5c4c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -28,17 +28,12 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.MyApp">
-
-
+ android:screenOrientation="portrait">
@@ -46,25 +41,17 @@
+
-
-
-
-
+
+ apply(Ljava/lang/Object;)Ljava/l
HSPLme/rosuh/easywatermark/ui/MainViewModel;->(La6/h;La6/k;La6/b;La6/g;)V
HSPLme/rosuh/easywatermark/ui/MainViewModel;->b()V
HSPLme/rosuh/easywatermark/ui/MainViewModel;->g()V
-HSPLme/rosuh/easywatermark/ui/widget/CenterLayoutManager$a$a;->()V
-HSPLme/rosuh/easywatermark/ui/widget/CenterLayoutManager$a$a;->()V
-HSPLme/rosuh/easywatermark/ui/widget/CenterLayoutManager$a$b;->()V
-HSPLme/rosuh/easywatermark/ui/widget/CenterLayoutManager$a$b;->()V
-HSPLme/rosuh/easywatermark/ui/widget/CenterLayoutManager$a;->(Landroid/content/Context;)V
-HSPLme/rosuh/easywatermark/ui/widget/CenterLayoutManager$a;->d()V
-HSPLme/rosuh/easywatermark/ui/widget/CenterLayoutManager$a;->e()V
-HSPLme/rosuh/easywatermark/ui/widget/CenterLayoutManager$b;->(Lme/rosuh/easywatermark/ui/widget/CenterLayoutManager;)V
-HSPLme/rosuh/easywatermark/ui/widget/CenterLayoutManager$b;->m()Ljava/lang/Object;
-HSPLme/rosuh/easywatermark/ui/widget/CenterLayoutManager$c;->(Lme/rosuh/easywatermark/ui/widget/CenterLayoutManager;)V
-HSPLme/rosuh/easywatermark/ui/widget/CenterLayoutManager$c;->m()Ljava/lang/Object;
-HSPLme/rosuh/easywatermark/ui/widget/CenterLayoutManager;->(Landroid/content/Context;)V
-HSPLme/rosuh/easywatermark/ui/widget/CenterLayoutManager;->u0(Landroidx/recyclerview/widget/RecyclerView;Landroidx/recyclerview/widget/RecyclerView$y;I)V
HSPLme/rosuh/easywatermark/ui/widget/ColoredImageVIew$a;->(Lme/rosuh/easywatermark/ui/widget/ColoredImageVIew;)V
HSPLme/rosuh/easywatermark/ui/widget/ColoredImageVIew$a;->m()Ljava/lang/Object;
HSPLme/rosuh/easywatermark/ui/widget/ColoredImageVIew$b;->()V
@@ -3304,9 +3291,6 @@ Lc5/a;
Lc6/a$a;
Lc6/a$b;
Lc6/a;
-Lcom/bumptech/glide/manager/g;
-Lcom/bumptech/glide/manager/h;
-Lcom/bumptech/glide/manager/o;
Lcom/google/android/material/appbar/MaterialToolbar;
Lcom/google/android/material/bottomsheet/c;
Lcom/google/android/material/button/MaterialButton;
@@ -3645,17 +3629,10 @@ Lme/rosuh/easywatermark/ui/MainActivity$k;
Lme/rosuh/easywatermark/ui/MainActivity;
Lme/rosuh/easywatermark/ui/MainViewModel$b;
Lme/rosuh/easywatermark/ui/MainViewModel;
-Lme/rosuh/easywatermark/ui/widget/CenterLayoutManager$a$a;
-Lme/rosuh/easywatermark/ui/widget/CenterLayoutManager$a$b;
-Lme/rosuh/easywatermark/ui/widget/CenterLayoutManager$a;
-Lme/rosuh/easywatermark/ui/widget/CenterLayoutManager$b;
-Lme/rosuh/easywatermark/ui/widget/CenterLayoutManager$c;
-Lme/rosuh/easywatermark/ui/widget/CenterLayoutManager;
Lme/rosuh/easywatermark/ui/widget/ColoredImageVIew$a;
Lme/rosuh/easywatermark/ui/widget/ColoredImageVIew$b;
Lme/rosuh/easywatermark/ui/widget/ColoredImageVIew$c;
Lme/rosuh/easywatermark/ui/widget/ColoredImageVIew;
-Lme/rosuh/easywatermark/ui/widget/ScalebleGridLayoutManager;
Ln0/b;
Ln0/e;
Ln1/a;
diff --git a/app/src/main/java/me/rosuh/easywatermark/GlideModule.kt b/app/src/main/java/me/rosuh/easywatermark/GlideModule.kt
deleted file mode 100644
index 8b78c0c4..00000000
--- a/app/src/main/java/me/rosuh/easywatermark/GlideModule.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package me.rosuh.easywatermark
-
-import android.content.Context
-import com.bumptech.glide.GlideBuilder
-import com.bumptech.glide.annotation.GlideModule
-import com.bumptech.glide.load.DecodeFormat
-import com.bumptech.glide.module.AppGlideModule
-import com.bumptech.glide.request.RequestOptions
-
-@GlideModule
-class CustomGlideModule : AppGlideModule() {
- override fun applyOptions(context: Context, builder: GlideBuilder) {
- builder.setDefaultRequestOptions(
- RequestOptions().format(DecodeFormat.PREFER_RGB_565)
- )
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/me/rosuh/easywatermark/MyApp.kt b/app/src/main/java/me/rosuh/easywatermark/MyApp.kt
index df1b511e..d2dcaa52 100644
--- a/app/src/main/java/me/rosuh/easywatermark/MyApp.kt
+++ b/app/src/main/java/me/rosuh/easywatermark/MyApp.kt
@@ -6,18 +6,19 @@ import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.core.content.edit
-import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.*
import me.rosuh.cmonet.CMonet
import me.rosuh.easywatermark.data.repo.WaterMarkRepository
-import javax.inject.Inject
+import me.rosuh.easywatermark.di.appModule
+import org.koin.android.ext.android.inject
+import org.koin.android.ext.koin.androidContext
+import org.koin.android.ext.koin.androidLogger
+import org.koin.core.context.startKoin
import kotlin.system.exitProcess
-@HiltAndroidApp
class MyApp : Application() {
- @Inject
- lateinit var waterMarkRepo: WaterMarkRepository
+ private val waterMarkRepo: WaterMarkRepository by inject()
private val sp by lazy { getSharedPreferences(SP_NAME, Context.MODE_PRIVATE) }
@@ -29,13 +30,20 @@ class MyApp : Application() {
override fun onCreate() {
super.onCreate()
+ startKoin {
+ // 将 Koin 日志记录到 Android logger
+ androidLogger()
+ // 引用 Android 上下文
+ androidContext(this@MyApp)
+ modules(appModule)
+ }
+ CMonet.init(this, true)
if (checkRecoveryMode()) {
return
} else {
applicationScope.launch {
waterMarkRepo.resetModeToText()
}
- CMonet.init(this, true)
}
}
diff --git a/app/src/main/java/me/rosuh/easywatermark/data/db/AppDataBase.kt b/app/src/main/java/me/rosuh/easywatermark/data/db/AppDataBase.kt
deleted file mode 100644
index 9042b90d..00000000
--- a/app/src/main/java/me/rosuh/easywatermark/data/db/AppDataBase.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-package me.rosuh.easywatermark.data.db
-
-import androidx.room.Database
-import androidx.room.RoomDatabase
-import androidx.room.TypeConverters
-import me.rosuh.easywatermark.data.db.dao.TemplateDao
-import me.rosuh.easywatermark.data.model.entity.Template
-
-@Database(entities = [Template::class], version = 1, exportSchema = false)
-@TypeConverters(DateConverter::class)
-abstract class AppDatabase : RoomDatabase() {
- abstract fun templateDao(): TemplateDao
-}
\ No newline at end of file
diff --git a/app/src/main/java/me/rosuh/easywatermark/data/db/DateConverter.kt b/app/src/main/java/me/rosuh/easywatermark/data/db/DateConverter.kt
deleted file mode 100644
index 81036913..00000000
--- a/app/src/main/java/me/rosuh/easywatermark/data/db/DateConverter.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package me.rosuh.easywatermark.data.db
-
-import androidx.room.TypeConverter
-import java.util.Date
-
-class DateConverter {
- @TypeConverter
- fun fromTimestamp(value: Long?): Date? {
- return value?.let { Date(it) }
- }
-
- @TypeConverter
- fun dateToTimestamp(date: Date?): Long? {
- return date?.time
- }
-}
diff --git a/app/src/main/java/me/rosuh/easywatermark/data/model/FuncTitleModel.kt b/app/src/main/java/me/rosuh/easywatermark/data/model/FuncTitleModel.kt
index c755d665..40af4a09 100644
--- a/app/src/main/java/me/rosuh/easywatermark/data/model/FuncTitleModel.kt
+++ b/app/src/main/java/me/rosuh/easywatermark/data/model/FuncTitleModel.kt
@@ -2,30 +2,15 @@ package me.rosuh.easywatermark.data.model
import androidx.annotation.DrawableRes
import androidx.annotation.Keep
+import androidx.annotation.StringRes
+// S4d-72: `type` now uses the platform-neutral `FuncType` (commonMain). `FuncTitleModel` stays `:app`
+// because it carries Android `@StringRes`/`@DrawableRes` ids; the former nested `FuncType` sealed class
+// was moved to `shared/commonMain` (same `FuncType.*` names).
@Keep
data class FuncTitleModel(
var type: FuncType,
- var title: String,
- @DrawableRes var iconRes: Int
-) {
- sealed class FuncType {
- object Text : FuncType()
- object Icon : FuncType()
- object Color : FuncType() {
- val tag = "Color"
- }
- object Alpha : FuncType()
- object Degree : FuncType()
- object TextStyle : FuncType()
- object Vertical : FuncType()
- object Horizon : FuncType()
- object TextSize : FuncType() {
- val tag = "TextSize"
- }
-
- object TileMode : FuncType() {
- val tag = "TileMode"
- }
- }
-}
+ @param:StringRes var title: Int,
+ @param:DrawableRes var iconRes: Int,
+ val valueRange: ClosedFloatingPointRange = 0f..100f,
+)
diff --git a/app/src/main/java/me/rosuh/easywatermark/data/model/SerializableSealClass.kt b/app/src/main/java/me/rosuh/easywatermark/data/model/SerializableSealClass.kt
deleted file mode 100644
index da364a88..00000000
--- a/app/src/main/java/me/rosuh/easywatermark/data/model/SerializableSealClass.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package me.rosuh.easywatermark.data.model
-
-import java.io.Serializable
-
-/**
- * An easy way to make sealed class serializable.
- * @author rosuh
- * @date 2021/6/27
- */
-sealed interface SerializableSealClass {
- /**
- * return serializable key for specify class
- * @author rosuh
- * @date 2021/6/27
- */
- fun serializeKey(): T
-}
diff --git a/app/src/main/java/me/rosuh/easywatermark/data/model/TextPaintStyle.kt b/app/src/main/java/me/rosuh/easywatermark/data/model/TextPaintStyle.kt
deleted file mode 100644
index b9c4132a..00000000
--- a/app/src/main/java/me/rosuh/easywatermark/data/model/TextPaintStyle.kt
+++ /dev/null
@@ -1,49 +0,0 @@
-package me.rosuh.easywatermark.data.model
-
-import android.graphics.Paint
-import android.widget.TextView
-
-sealed class TextPaintStyle : SerializableSealClass {
-
- abstract fun applyStyle(tv: TextView?)
-
- abstract fun obtainSysStyle(): Paint.Style
-
- object Fill : TextPaintStyle() {
- override fun applyStyle(tv: TextView?) {
- tv?.paint?.style = Paint.Style.FILL
- }
-
- override fun obtainSysStyle(): Paint.Style {
- return Paint.Style.FILL
- }
-
- override fun serializeKey(): Int {
- return 0
- }
- }
-
- object Stroke : TextPaintStyle() {
- override fun applyStyle(tv: TextView?) {
- tv?.paint?.style = Paint.Style.STROKE
- }
-
- override fun obtainSysStyle(): Paint.Style {
- return Paint.Style.STROKE
- }
-
- override fun serializeKey(): Int {
- return 1
- }
- }
-
- companion object {
- fun obtainSealedClass(key: Int): TextPaintStyle {
- return when (key) {
- 0 -> Fill
- 1 -> Stroke
- else -> throw IllegalArgumentException("No such key for TextPaintStyle")
- }
- }
- }
-}
diff --git a/app/src/main/java/me/rosuh/easywatermark/data/model/TextTypeface.kt b/app/src/main/java/me/rosuh/easywatermark/data/model/TextTypeface.kt
deleted file mode 100644
index 785b5d00..00000000
--- a/app/src/main/java/me/rosuh/easywatermark/data/model/TextTypeface.kt
+++ /dev/null
@@ -1,83 +0,0 @@
-package me.rosuh.easywatermark.data.model
-
-import android.widget.TextView
-
-/**
- * Sealed for [android.graphics.Typeface]
- * @author rosuh
- * @date 2021/6/27
- */
-sealed class TextTypeface : SerializableSealClass {
-
- abstract fun applyStyle(tv: TextView?)
-
- abstract fun obtainSysTypeface(): Int
-
- object Normal : TextTypeface() {
- override fun applyStyle(tv: TextView?) {
- tv?.setTypeface(tv.typeface, android.graphics.Typeface.NORMAL)
- }
-
- override fun obtainSysTypeface(): Int {
- return android.graphics.Typeface.NORMAL
- }
-
- override fun serializeKey(): Int {
- return 0
- }
- }
-
- object Italic : TextTypeface() {
- override fun applyStyle(tv: TextView?) {
- tv?.setTypeface(tv.typeface, android.graphics.Typeface.ITALIC)
- }
-
- override fun obtainSysTypeface(): Int {
- return android.graphics.Typeface.ITALIC
- }
-
- override fun serializeKey(): Int {
- return 1
- }
- }
-
- object Bold : TextTypeface() {
- override fun applyStyle(tv: TextView?) {
- tv?.setTypeface(tv.typeface, android.graphics.Typeface.BOLD)
- }
-
- override fun obtainSysTypeface(): Int {
- return android.graphics.Typeface.BOLD
- }
-
- override fun serializeKey(): Int {
- return 2
- }
- }
-
- object BoldItalic : TextTypeface() {
- override fun applyStyle(tv: TextView?) {
- tv?.setTypeface(tv.typeface, android.graphics.Typeface.BOLD_ITALIC)
- }
-
- override fun obtainSysTypeface(): Int {
- return android.graphics.Typeface.BOLD_ITALIC
- }
-
- override fun serializeKey(): Int {
- return 3
- }
- }
-
- companion object {
- fun obtainSealedClass(key: Int): TextTypeface {
- return when (key) {
- 0 -> Normal
- 1 -> Italic
- 2 -> Bold
- 3 -> BoldItalic
- else -> throw IllegalArgumentException("No such key for TextTypeface")
- }
- }
- }
-}
diff --git a/app/src/main/java/me/rosuh/easywatermark/data/model/UserPreferences.kt b/app/src/main/java/me/rosuh/easywatermark/data/model/UserPreferences.kt
deleted file mode 100644
index cad0fe68..00000000
--- a/app/src/main/java/me/rosuh/easywatermark/data/model/UserPreferences.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-package me.rosuh.easywatermark.data.model
-
-import android.graphics.Bitmap
-import androidx.annotation.Keep
-import me.rosuh.easywatermark.data.repo.UserConfigRepository
-
-@Keep
-data class UserPreferences(
- val outputFormat: Bitmap.CompressFormat,
- val compressLevel: Int
-) {
- companion object {
- val DEFAULT = UserPreferences(
- UserConfigRepository.DEFAULT_BITMAP_COMPRESS_FORMAT,
- UserConfigRepository.DEFAULT_COMPRESS_LEVEL
- )
- }
-}
diff --git a/app/src/main/java/me/rosuh/easywatermark/data/model/ViewInfo.kt b/app/src/main/java/me/rosuh/easywatermark/data/model/ViewInfo.kt
deleted file mode 100644
index 4fd67014..00000000
--- a/app/src/main/java/me/rosuh/easywatermark/data/model/ViewInfo.kt
+++ /dev/null
@@ -1,30 +0,0 @@
-package me.rosuh.easywatermark.data.model
-
-import android.graphics.Matrix
-import android.widget.ImageView
-
-data class ViewInfo(
- val width: Int,
- val height: Int,
- val paddingLeft: Int,
- val paddingTop: Int,
- val paddingRight: Int,
- val paddingBottom: Int,
- val scaleType: ImageView.ScaleType,
- val matrix: Matrix,
-) {
- companion object {
- fun from(imageView: ImageView): ViewInfo {
- return ViewInfo(
- imageView.width,
- imageView.height,
- imageView.paddingLeft,
- imageView.paddingTop,
- imageView.paddingRight,
- imageView.paddingBottom,
- imageView.scaleType,
- imageView.matrix
- )
- }
- }
-}
diff --git a/app/src/main/java/me/rosuh/easywatermark/data/model/WaterMark.kt b/app/src/main/java/me/rosuh/easywatermark/data/model/WaterMark.kt
deleted file mode 100644
index 334ba8d8..00000000
--- a/app/src/main/java/me/rosuh/easywatermark/data/model/WaterMark.kt
+++ /dev/null
@@ -1,28 +0,0 @@
-package me.rosuh.easywatermark.data.model
-
-import android.graphics.Shader
-import android.net.Uri
-import android.os.Build
-import androidx.annotation.Keep
-import me.rosuh.easywatermark.data.repo.WaterMarkRepository
-
-@Keep
-data class WaterMark(
- val text: String,
- val textSize: Float,
- val textColor: Int,
- val textStyle: TextPaintStyle,
- val textTypeface: TextTypeface,
- val alpha: Int,
- val degree: Float,
- val hGap: Int,
- val vGap: Int,
- val iconUri: Uri,
- val markMode: WaterMarkRepository.MarkMode,
- val enableBounds: Boolean,
- val tileMode: Shader.TileMode,
-) {
- fun obtainTileMode(): Shader.TileMode {
- return tileMode
- }
-}
diff --git a/app/src/main/java/me/rosuh/easywatermark/data/model/entity/Template.kt b/app/src/main/java/me/rosuh/easywatermark/data/model/entity/Template.kt
deleted file mode 100644
index 192fcd39..00000000
--- a/app/src/main/java/me/rosuh/easywatermark/data/model/entity/Template.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-package me.rosuh.easywatermark.data.model.entity
-
-import android.os.Parcelable
-import androidx.room.ColumnInfo
-import androidx.room.Entity
-import androidx.room.PrimaryKey
-import kotlinx.parcelize.Parcelize
-import java.util.Date
-
-@Entity
-@Parcelize
-data class Template(
- @PrimaryKey(autoGenerate = true) var id: Int,
- @ColumnInfo(name = "content") val content: String?,
- @ColumnInfo(name = "creation_date") var creationDate: Date?,
- @ColumnInfo(name = "last_modified_date") var lastModifiedDate: Date?,
-) : Parcelable {
-// @PrimaryKey(autoGenerate = true)
-// var id: Int = 0
-// private companion object : Parceler {
-// override fun Template.write(parcel: Parcel, flags: Int) {
-// parcel.writeInt(id)
-// parcel.writeString(content)
-// parcel.writeSerializable(creationDate)
-// parcel.writeSerializable(lastModifiedDate)
-// }
-//
-// override fun create(parcel: Parcel): Template {
-// // Custom read implementation
-// val id = parcel.readInt()
-// return Template(
-// parcel.readString(),
-// parcel.readSerializable() as Date,
-// parcel.readSerializable() as Date
-// ).apply {
-// this.id = id
-// }
-// }
-// }
-}
diff --git a/app/src/main/java/me/rosuh/easywatermark/data/repo/MemorySettingRepo.kt b/app/src/main/java/me/rosuh/easywatermark/data/repo/MemorySettingRepo.kt
index 88eb382a..0e78eeb8 100644
--- a/app/src/main/java/me/rosuh/easywatermark/data/repo/MemorySettingRepo.kt
+++ b/app/src/main/java/me/rosuh/easywatermark/data/repo/MemorySettingRepo.kt
@@ -1,34 +1,10 @@
package me.rosuh.easywatermark.data.repo
-import androidx.palette.graphics.Palette
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.stateIn
-import kotlinx.coroutines.launch
-import me.rosuh.easywatermark.MyApp
-import javax.inject.Inject
-import javax.inject.Singleton
-
/**
- * hold memory data for across business usage
+ * Holds in-memory settings shared across business usage.
+ *
+ * S4d-41: the dormant image-tint background-color members were removed — the Compose build never
+ * generated or consumed them (S4d-40 Option B). The class is intentionally kept (still DI-provided) as
+ * the home for future in-memory settings; it has no members today.
*/
-@Singleton
-class MemorySettingRepo @Inject constructor() {
- private val scope = CoroutineScope(Dispatchers.Main)
-
- /**
- * 从图片中提取的调色板,用于修改整体的主题配色
- * The color palette extracted from the picture, used to modify the overall theme color
- */
- private val _palette: MutableStateFlow = MutableStateFlow(null)
-
- val paletteFlow = _palette.stateIn(MyApp.applicationScope, SharingStarted.Eagerly, null)
-
- fun updatePalette(palette: Palette?) {
- scope.launch {
- _palette.emit(palette)
- }
- }
-}
\ No newline at end of file
+class MemorySettingRepo
diff --git a/app/src/main/java/me/rosuh/easywatermark/di/AppModule.kt b/app/src/main/java/me/rosuh/easywatermark/di/AppModule.kt
index ccea555c..e56cbf6e 100644
--- a/app/src/main/java/me/rosuh/easywatermark/di/AppModule.kt
+++ b/app/src/main/java/me/rosuh/easywatermark/di/AppModule.kt
@@ -1,41 +1,28 @@
package me.rosuh.easywatermark.di
-import android.content.Context
-import androidx.room.Room
-import dagger.Module
-import dagger.Provides
-import dagger.hilt.InstallIn
-import dagger.hilt.android.qualifiers.ApplicationContext
-import dagger.hilt.components.SingletonComponent
import me.rosuh.easywatermark.data.db.AppDatabase
-import java.util.Locale
-import javax.inject.Singleton
+import me.rosuh.easywatermark.data.db.buildTemplateDatabase
+import me.rosuh.easywatermark.platform.AndroidDynamicColorCapability
+import me.rosuh.easywatermark.platform.DynamicColorCapability
+import me.rosuh.easywatermark.ui.MainViewModel
+import me.rosuh.easywatermark.ui.about.AboutViewModel
+import org.koin.core.module.dsl.viewModel
+import org.koin.dsl.module
-@Module
-@InstallIn(SingletonComponent::class)
-object AppModule {
-
- @Singleton
- @Provides
- fun provideYourDatabase(
- @ApplicationContext app: Context
- ): AppDatabase? {
- val builder = Room.databaseBuilder(
- app,
- AppDatabase::class.java,
- "ewm-db"
- )
- val isCh = Locale.getDefault().language.contains("zh")
- builder.createFromAsset(if (isCh) "ewm-db-ch.db" else "ewm-db-eng.db")
- try {
- return builder.build()
- } catch (e: Exception) {
- e.printStackTrace()
- }
- return null
+val appModule = module {
+ single {
+ // S4d-92: Android creation moved to :shared androidMain (locale createFromAsset + in-memory
+ // fallback preserved, byte-identical). See data/db/TemplateDatabaseBuilder.android.kt.
+ buildTemplateDatabase(get())
+ }
+ includes(repositoryModule)
+ single {
+ AndroidDynamicColorCapability()
+ }
+ viewModel {
+ MainViewModel(get(), get(), get(), get())
+ }
+ viewModel {
+ AboutViewModel(get(), get(), get())
}
-
- @Singleton
- @Provides
- fun provideTemplateDao(db: AppDatabase?) = db?.templateDao()
}
\ No newline at end of file
diff --git a/app/src/main/java/me/rosuh/easywatermark/di/DataStoreModule.kt b/app/src/main/java/me/rosuh/easywatermark/di/DataStoreModule.kt
index ad66f93b..49f66a56 100644
--- a/app/src/main/java/me/rosuh/easywatermark/di/DataStoreModule.kt
+++ b/app/src/main/java/me/rosuh/easywatermark/di/DataStoreModule.kt
@@ -2,51 +2,35 @@ package me.rosuh.easywatermark.di
import android.content.Context
import androidx.datastore.core.DataStore
-import androidx.datastore.preferences.SharedPreferencesMigration
import androidx.datastore.preferences.core.Preferences
-import androidx.datastore.preferences.preferencesDataStore
-import dagger.Module
-import dagger.Provides
-import dagger.hilt.InstallIn
-import dagger.hilt.android.qualifiers.ApplicationContext
-import dagger.hilt.components.SingletonComponent
+import me.rosuh.easywatermark.data.datastore.createPreferencesDataStore
import me.rosuh.easywatermark.data.repo.UserConfigRepository
import me.rosuh.easywatermark.data.repo.WaterMarkRepository
-import javax.inject.Named
-import javax.inject.Singleton
-@Module
-@InstallIn(SingletonComponent::class)
-object DataStoreModule {
+/**
+ * S4d-74: store creation now lives in `:shared/androidMain` ([createPreferencesDataStore]), preserving
+ * the exact legacy file path (`filesDir/datastore/.preferences_pb`) + `SharedPreferencesMigration`.
+ * These extension properties keep the same names/types so consumers (`RepositoryModule`) are unchanged,
+ * and back them with a process-wide single instance per file — the old `by preferencesDataStore(...)`
+ * delegate's guarantee (DataStore forbids a second active store for the same file). Keyed on the
+ * application context.
+ */
+private val storeLock = Any()
- @Named("UserPreferences")
- @Singleton
- @Provides
- fun userDataStore(@ApplicationContext context: Context): DataStore {
- return context.userDataStore
- }
+@Volatile
+private var userStore: DataStore? = null
- @Named("WaterMarkPreferences")
- @Singleton
- @Provides
- fun provideWaterMarkDataStore(@ApplicationContext context: Context): DataStore {
- return context.waterMarkDataStore
- }
-}
+@Volatile
+private var waterMarkStore: DataStore? = null
-val Context.userDataStore: DataStore by preferencesDataStore(
- name = UserConfigRepository.SP_NAME,
- produceMigrations = { ctx -> listOf(SharedPreferencesMigration(ctx, UserConfigRepository.SP_NAME)) }
-)
+val Context.userDataStore: DataStore
+ get() = userStore ?: synchronized(storeLock) {
+ userStore ?: createPreferencesDataStore(applicationContext, UserConfigRepository.SP_NAME)
+ .also { userStore = it }
+ }
-val Context.waterMarkDataStore: DataStore by preferencesDataStore(
- name = WaterMarkRepository.SP_NAME,
- produceMigrations = { ctx ->
- listOf(
- SharedPreferencesMigration(
- ctx,
- WaterMarkRepository.SP_NAME
- )
- )
+val Context.waterMarkDataStore: DataStore
+ get() = waterMarkStore ?: synchronized(storeLock) {
+ waterMarkStore ?: createPreferencesDataStore(applicationContext, WaterMarkRepository.SP_NAME)
+ .also { waterMarkStore = it }
}
-)
\ No newline at end of file
diff --git a/app/src/main/java/me/rosuh/easywatermark/di/RepositoryModule.kt b/app/src/main/java/me/rosuh/easywatermark/di/RepositoryModule.kt
index 59b827b6..97b8b9b3 100644
--- a/app/src/main/java/me/rosuh/easywatermark/di/RepositoryModule.kt
+++ b/app/src/main/java/me/rosuh/easywatermark/di/RepositoryModule.kt
@@ -1,46 +1,44 @@
package me.rosuh.easywatermark.di
+import android.content.Context
+import android.util.Log
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
-import dagger.Module
-import dagger.Provides
-import dagger.hilt.InstallIn
-import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.Dispatchers
+import me.rosuh.easywatermark.data.db.AppDatabase
import me.rosuh.easywatermark.data.db.dao.TemplateDao
import me.rosuh.easywatermark.data.repo.MemorySettingRepo
import me.rosuh.easywatermark.data.repo.TemplateRepository
+import me.rosuh.easywatermark.R
import me.rosuh.easywatermark.data.repo.UserConfigRepository
import me.rosuh.easywatermark.data.repo.WaterMarkRepository
-import javax.inject.Named
-import javax.inject.Singleton
+import me.rosuh.easywatermark.utils.ktx.toWatermarkTileMode
+import org.koin.dsl.module
-@Module
-@InstallIn(SingletonComponent::class)
-object RepositoryModule {
-
- @Named("UserPreferences")
- @Provides
- @Singleton
- fun provideUserRepository(dataStore: DataStore