diff --git a/DeviceMasker-main/.agents/skills/android-cli/SKILL.md b/DeviceMasker-main/.agents/skills/android-cli/SKILL.md new file mode 100644 index 000000000..595401dad --- /dev/null +++ b/DeviceMasker-main/.agents/skills/android-cli/SKILL.md @@ -0,0 +1,207 @@ +--- +name: android-cli +description: Orchestrates Android development tasks including project creation, deployment, SDK management, and environment diagnostics using the `android` command-line tool. +license: Complete terms in LICENSE.txt +metadata: + author: Google LLC + keywords: + - sdk + - emulator + - skills + - docs + - knowledge base + - project creation + - screenshots +--- +# Android CLI Specialist + +This skill provides instructions for using the `android` CLI tool. The tool includes various commands for creating projects, running applications, interacting with devices, and managing the CLI environment. + +## SDK management +To manage the installation of Android SDKs and tools, use the `sdk` command. For example: + +- `android sdk install [@]...`: Install specific packages. Multiple packages can be specified, separated by spaces. `` defaults to latest. For example: `android sdk install platforms/android-30@2 platforms/android-34` +- `android sdk update []`: Update a specific package or all packages to the latest version. +- `android sdk remove `: Remove a package from the local SDK. +- `android sdk list --all`: List installed and available SDK packages. + +## Project creation +Create projects from templates using the `create` command. + +For example: `android create empty-activity --name="My App" --output=./my-app` + +## Interacting with devices +For more information on interacting with running devices, see [here](references/interact.md) + +## Running journey tests +For more information on running journeys, see [here](references/journeys.md) + +## Doc searching +The `docs` command searches authoritative, high-quality Android developer documentation in the Android Knowledge Base. +By providing a few keywords, this tool will return high quality articles that contain examples or guidance on how to use Android APIs or libraries. +Use this tool to obtain additional information on how to achieve Android-specific tasks or to know more about Android APIs, surfaces, libraries, or devices. + +Always use this tool to get the most up-to-date information about Android concepts. Typical good use cases are: + - Finding migration guides for APIs. + - Finding examples for APIs. + - Finding up-to-date information about Android APIs. + - Finding best practices for Android concepts. + +## Running APKs +Use the `run` command to run Android apps. + +## Managing emulators + +Manage Android Virtual Devices (AVDs) using the `android emulator` command + +## Capturing screenshots + +Capture an image of the current screen of a connected Android device and output it to a file using the `android screenshot` command. + +## Managing skills + +Manage antigravity agent skills for Android using the `android skills` command. + +## Inspecting UI Layouts + +Use the `android layout` command to inspect the UI layout of an Android application. It returns the layout tree of an Android application in JSON format. When debugging UI errors, this is often a much faster approach than taking a screenshot. + +## Updating the CLI + +Update the Android CLI using the `android update` command. + +# `android help` output + +Usage: android [-hV] [--sdk=PARAM] [COMMAND] + -h, --help Show this help message and exit. + --sdk=PARAM Path to the Android SDK + -V, --version Print version information and exit. +Commands: + create Create a new Android project + describe Analyzes an Android project to generate descriptive metadata. + docs Android documentation commands + emulator Emulator commands + help Shows the help of all commands + info Print environment information (SDK Location, etc.) + init Initializes the environment (eg. skills) for Android CLI. + layout Returns the layout tree of an application + run Deploy an Android Application + screen Commands to view the device + sdk Download and list SDK packages + skills Manage skills + update Update the Android CLI + +create + Usage: android create [-h] [--verbose] [--list] [--minSdk=api] + --name=applicationName [-o=dest-path] [template-name] + Create a new Android project + [template-name] The template name + -h, --help Show this help message and exit. + --minSdk=api The 'minSdk' supported by the application (default + is defined in the template) + --name=applicationName + The name of the application (e.g. 'My Application') + -o, --output=dest-path The destination project directory path (default is + '.') + --verbose Enables verbose output + --list List all available templates + +describe + Usage: android describe [-hV] [--project_dir=PARAM] + Analyzes an Android project to generate descriptive metadata. + This command identifies and outputs the paths to JSON files that detail the + project's structure, including build targets and their corresponding output + artifact locations (e.g., APKs). This information enables other tools and + commands to locate build artifacts efficiently. + -h, --help Show this help message and exit. + --project_dir=PARAM The project directory to describe + -V, --version Print version information and exit. + +docs + Usage: android docs [-h] [COMMAND] + Android documentation commands + -h, --help Show this help message and exit. + Commands: + search Search Android documentation + fetch Fetch Android documentation + +emulator + Usage: android emulator [-h] [COMMAND] + Emulator commands + -h, --help Show this help message and exit. + Commands: + create Creates a virtual device + start Launches the specified virtual device. This command will return when + the emulator is fully started and ready to use. + stop Stops the specified virtual device + list Lists available virtual devices + remove Delete a virtual device + +help + Usage: android help [COMMAND] + Shows the help of all commands + [COMMAND] The command to show help for + +info + Usage: android info + Print environment information (SDK Location, etc.) + The specific field to print the value of. If omitted print all. + +init + Usage: android init + Initializes the environment (eg. skills) for Android CLI. + +layout + Usage: android layout [-dhp] [--device=PARAM] [-o=PARAM] + Returns the layout tree of an application + -d, --diff Returns a flat list of the layout elements that have + changed since the last invocation of ui-dump + --device=PARAM The device serial number + -h, --help Show this help message and exit. + -o, --output=PARAM Writes the layout tree to the specified file or + directory. If omitted, prints the tree to standard + output + -p, --pretty Pretty-prints the returned JSON + +run + Usage: android run [-h] [--debug] [--activity=PARAM] [--device=PARAM] + [--type=PARAM] [--apks=PARAM[,PARAM...]]... + Deploy an Android Application + --activity=PARAM The activity name + --apks=PARAM[,PARAM...] + The paths to the APKs + --debug Run in debug mode + --device=PARAM The device serial number + -h, --help Show this help message and exit. + --type=PARAM The component type (ACTIVITY, SERVICE, etc.) + +screen + Usage: android screen [-h] [COMMAND] + Commands to view the device + -h, --help Show this help message and exit. + Commands: + capture Outputs the device screen to a PNG + resolve Target UI elements visually + +sdk + Usage: android sdk [COMMAND] + Download and list SDK packages + Commands: + install Install SDK packages + update Update one or all packages to the latest version + remove Remove a package from the SDK + list List installed and available SDK packages + +skills + Usage: android skills [COMMAND] + Manage skills + Commands: + add Install a skill + remove Remove a skill + list List available skills + find Find skills by keyword + +update + Usage: android update [--url=PARAM] + Update the Android CLI + --url=PARAM The URL to download the update from \ No newline at end of file diff --git a/DeviceMasker-main/.agents/skills/android-cli/references/interact.md b/DeviceMasker-main/.agents/skills/android-cli/references/interact.md new file mode 100644 index 000000000..099e66ba2 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/android-cli/references/interact.md @@ -0,0 +1,83 @@ +# Tools +Run `android layout --help` and `android screen --help`. + +## UI Dump +`android layout` returns a flat JSON list of the UI elements on screen. +`android layout --diff` returns a flat JSON list of the UI elements that have changed since the last call to `layout` or `layout --diff` + +Each JSON object represents a UI element in the Android app. The following properties may be present: +- `text` - any literal text the element contains +- `resourceId` - the Android resource id used to refer to the element +- `contentDesc` - a description of a UI element for use by accessibility tools +- `interactions` - the set of user interactions the element supports. May contain one or more of: `checkable`, `clickable`, `focusable`, `scrollable`, `long-clickable`, `password` +- `state` - the set of states the element is in. May contain one or more of `checked`, `focused`, `selected` +- `bounds` - the screen coordinates of the bounding rectangle of the element, in the format `[min X,min Y][max X, max Y]` +- `center` - the screen coordinates of the center of the element, in the format `[x,y]` +- `off-screen` - if true, the element is in the UI hierarchy but not visible; it may require scrolling to view. + +Use `layout` as a primary means of examining an Android app. Use `layout --diff` to focus on changes and to keep your context small. +Example: When entering digits into a calculator, use `layout --diff` to output only the digit readout element. + +`layout` may fail due to the app displaying a WebView or animation; in these cases, use `android screen --annotate` to inspect the app. +This failure will likely resolve after navigating away from the current screen. + +## Screenshot +`android screen capture -o ` saves a PNG of the current device screen to `` + +Use `screen capture` as a secondary means of examining an Android app +Examples: +- Understanding the content of an on-screen image +- Looking at a `WebView` (web content does not always appear in the ui dump) +- Trying to find a UI element by its visual appearance + +**IMPORTANT**: Always *VISUALLY* examine the PNG image returned from `android screen` BEFORE doing anything else. + +## Annotated Screenshot +`android screen capture --annotate -o ` +`android screen resolve --screen --string ` + +The `--annotate` command adds numerical labels and bounding boxes around UI elements. Use this command to locate UI elements that cannot +be located in the `layout` output. + +**IMPORTANT**: When using `android screen --annotate`, always *VISUALLY* examine the resulting PNG file. + +To refer to these labels in input commands, use `screen resolve` to convert labels into coordinates: + +`android screen resolve --screen --string "#3"` returns ` ` + +To save turns, you can combine shell commands: + +`adb shell input $(android screen resolve --screen screen.png --string "tap #34")` + +This command taps on region #34 from `screen.png` + +## Input +Use `adb shell input` for interacting with Android devices. +Refer to the `"interactions"` property of an element for what interactions can be performed on a particular element. + +Interact with UI elements with their `center` coordinate or their `bounds` coordinates: +```json +{ + "key": -248568265, + "class": "android.widget.Button", + "bounds": "[138,9][167,38]", + "center": "[152,23]" +} +``` +To tap on this button, you would execute `adb shell input tap 152 23`. This taps the center. + +```json +{ + "key": 12487234, + "class": "com.example.ui.ScrollableList", + "bounds": "[100,200][400,600]", + "center": "[250,400]" +} +``` +To scroll down on this list, you would execute `adb shell input swipe 250 400 600 500`. This swipes from the center to the bottom over 500ms. + +# Android Interaction Rules +1. Always ensure text input fields have `"focused"` in their `"state"` list before entering text +2. If an element has `"scrollable"` in its `"interactions"` list, try scrolling it when looking for missing UI elements +2. Always scroll slowly when executing scroll inputs. The 5th argument to `adb shell input swipe` controls scroll duration. +3. Content may take time to load; if a `layout` is missing information after you take an action, wait a few seconds, then perform `layout --diff` to see if anything changes. \ No newline at end of file diff --git a/DeviceMasker-main/.agents/skills/android-cli/references/journeys.md b/DeviceMasker-main/.agents/skills/android-cli/references/journeys.md new file mode 100644 index 000000000..74545ddff --- /dev/null +++ b/DeviceMasker-main/.agents/skills/android-cli/references/journeys.md @@ -0,0 +1,97 @@ +A journey is an XML-specified test of an Android app's behavior. It consists of a list of `` elements. For example: +```xml + + + A sample journey to illustrate the format + + + + Tap the "Home" icon + + + Verify that the app is on its Home screen + + + +``` + +Evaluate a journey by proceeding through the `` list in sequential order. Evaluate each `` block individually. +A journey succeeds if all elements in the `` list succeed. + +A journey is a test case for an app. The journey XML is the source of truth; if the app disagrees with the journey, the app has failed. +Additionally, if the app exits, crashes, or freezes, journey evaluation stops and the journey fails. + +**IMPORTANT** - Execute each step EXACTLY as written, and independently of other steps! If an action says to `"tap the first search result"`, +you MUST find the search results and tap the first one. Do this even if you believe you know the intent behind the action. + +## Taking Actions +Some `` elements specify UI interactions to perform on the running Android app. Perform the interaction and verify that the app does +not crash or behave in an unexpected manner. This is the *only* verification you should perform for an ``. + +If the interaction cannot be performed as specified, the journey fails. +Example: +```Click the red button``` +If you determine a red button is not present in the UI, the journey fails. + +If the text of an `` specifies a list of actions, break it into sub-actions and evaluate them individually: +Example: +```Search for soda and add the first result to the cart``` +This should be evaluated as: +``` +Search for soda +Add the first result to the cart +``` + +If an `` contains something that is not a specification for a UI interaction, alert the user that the journey is malformed and exit +early, specifying the error in question. + +## Verifying Expectations +`` elements that begin with "check" or "verify" specify expectations for the current state of the Android app. Determine the current +state of the app and check if the expectations are met. + +Determine the current state of the app by inspecting the current screen of the device without interacting with it. +Example: +```Check if "Switch 2" is visible on the screen``` +This requires only inspecting the current screen, not scrolling or interacting. If "Switch 2" is not currently visible, the action fails. + +If the expectations are not met, mark the `` as a failure and the journey evaluation ends. A single `` may contain +multiple expectations. +Example: +```Verify that the app is on the Home screen, the Home icon is blue, and the temperature is displayed``` +This `` fails if ANY of the following are false: +- The app is on the Home screen +- There is a Home icon, and it is blue +- A temperature is displayed + +## Handling failure +When running a journey, evaluate it as a test. Failure is acceptable, and often expected. Proper reporting of failures is the priority. + +Keep debugging and troubleshooting to a minimum; assume that tools are showing you the correct output every time. The goal is to determine +if the *current* Android app can correctly handle the *current* steps outlined in the journey. Suggestions for bug fixes, clarification, or +other improvements should be kept to journey evaluation summary at the end. + +## Summarizing +For each `` you evaluated, output JSON describing the results. + +``` +{ + "journey:", The name of the journey + "results:" [ + { + // A string containing the full text of the + "action": "Click the blue button, + // "PASSED" if the instruction was evaluated, "FAILED" if the instruction could not be evaluated, or "SKIPPED" if journey evaluation ended early because an instruction failed + "status": "PASSED", + // A list of the ADB commands executed while evaluating the instruction, + "commands": [ "adb input swipe 490 200 500 500 500", "adb input tap 45 920" ], + // Failure reasons, feedback, or other useful information + "comment": "The journey step doesn't specify that the button requires scrolling to see", + }, + { + "action": "The home screen is shown", + "status": "FAILED", + "comment": "The settings page was shown", + }, + ] +} +``` \ No newline at end of file diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/README.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/README.md new file mode 100644 index 000000000..7ce734770 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/README.md @@ -0,0 +1,151 @@ +

+ +

+ +# Android Agent Skill + +![Kotlin](https://img.shields.io/badge/Kotlin-2.3.21-blue) +![AGP](https://img.shields.io/badge/AGP-9.2.0-orange) +![Min SDK](https://img.shields.io/badge/Min_SDK-24-green) +![Target SDK](https://img.shields.io/badge/Target_SDK-37-green) + +This repository is an **Agent Skill** package for Android development with Kotlin and Jetpack Compose. +It provides a structured set of instructions, templates, and references that help agents build +production-quality Android apps consistently and efficiently. + +Learn more about the Agent Skills format here: [agentskills.io](https://agentskills.io/home) + +Browse this skill on [SkillsMP](https://skillsmp.com/skills/drjacky-claude-android-ninja-skill-md) + +## What This Skill Covers +- Modular Android architecture (feature-first, core modules, strict dependencies) +- Domain/Data/UI layering patterns with auth-focused examples +- Jetpack Compose patterns, state management, animation, side effects, modifiers, and adaptive UI (NavigationSuiteScaffold, ListDetailPaneScaffold, SupportingPaneScaffold) +- Edge-to-edge display and predictive back gesture handling +- Material 3 theming (dynamic colors, typography, shapes, 8dp spacing tokens, app-category style fit, reserved resource names, dark/light mode) +- Navigation3 guidance, adaptive navigation, and large-screen quality tiers (phones, tablets, foldables, input expectations) +- Accessibility support (TalkBack, semantic properties, label copy, live regions, Espresso accessibility checks, WCAG alignment) +- Internationalization & localization (i18n/l10n, RTL support, plurals) +- Notifications (channels, styles, actions, foreground services, progress-centric, media/audio focus, PiP, system sharesheet, Navigation3 state from taps) +- Background media playback hardening at target SDK 37 (Media3 `MediaSessionService` for audio and video, `mediaPlayback` foreground service type, `FOREGROUND_SERVICE_MEDIA_PLAYBACK` permission, standalone `MediaPlayer`/`AudioTrack` forbidden in background, `requestAudioFocus` enforcement) +- Data synchronization & offline-first (sync strategies, conflict resolution, cache invalidation) +- Material Symbols icons, adaptive launcher icon specs, graphics, custom drawing with Canvas, and Coil3 image loading patterns (AsyncImage, SubcomposeAsyncImage, Hilt ImageLoader) +- Gradle/build conventions, product flavors and BuildConfig, version catalog usage, KSP migration, and build performance optimization (diagnostics, lazy tasks, configuration cache) +- Testing practices with fakes, Hilt testing, Room 3 testing (`SQLiteDriver`, `room3-testing`), Compose Preview Screenshot Testing and Roborazzi trade routing, pre-release UI state checklist (empty, loading, error, offline, permissions), ADB device targeting, install or launch smoke, and UIAutomator black-box checks (`references/testing.md`) +- Coroutines patterns, structured concurrency, Flow (callbackFlow, backpressure, combine, shareIn), and common pitfalls +- Kotlin delegation patterns and composition over inheritance +- Dependency management rules and templates +- Crash reporting with provider-agnostic interfaces (Firebase/Sentry) +- Runtime permissions with Compose patterns (`references/android-permissions.md`); media playback, picking, FileProvider, and sharesheet routing (`references/android-media.md`) +- Performance benchmarking (Macrobenchmark, Microbenchmark, Baseline Profiles, ProfileInstaller, System Tracing), Google Play Vitals context (crash/ANR bars, startup targets, frame budgets, battery/background), optional Play Developer Reporting API vitals, Compose recomposition optimization (three phases, deferred state reads, Strong Skipping Mode), and app startup optimization (App Startup library, splash screen, lazy initialization) +- StrictMode guardrails and Compose compiler stability diagnostics +- Code coverage with JaCoCo (unit + instrumented tests) +- Security (certificate pinning, encryption, biometrics, Credential Manager and passkeys, device identifiers and privacy, Play Data safety, Play Integrity Standard/Classic with server `decodeIntegrityToken`, `requestHash`/`nonce` binding, tiered policy, remediation, local root/emulator checks as supplementary) +- Retrofit/networking patterns (service interfaces, nullable JSON DTOs, Hilt NetworkModule, AuthInterceptor) +- Haptic feedback, touch targets, and forms/input patterns (keyboard types, autofill, validation) +- Debugging guide (Logcat levels, ANR timeouts, Gradle error patterns, LeakCanary, Compose recomposition, R8 mapping and manual de-obfuscation) +- Consolidated migration guide (XML to Compose, LiveData to StateFlow, RxJava to Coroutines, Navigation 2.x to Navigation3, Accompanist to official APIs, Material 2 to 3, Edge-to-Edge, Room 2.x to Room 3, Android 17 / API 37 checklist, 16 KB native page size and Play alignment, Compose-XML interop hardening, Splash Screen API; [`references/migration.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/references/migration.md)) +- Code quality with Detekt and Compose rules +- Play CI/CD: AAB, release tracks, signing boundaries, staged rollout, and upload automation (fastlane vs Gradle Play Publisher routing; [`references/android-ci-cd.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/references/android-ci-cd.md)) + +## Key Files +- [`SKILL.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/SKILL.md) - entry point and workflow decision tree +- [`references/architecture.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/references/architecture.md) - architecture principles, data/domain/ui/common layers, nullable network DTOs, and flows +- [`references/modularization.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/references/modularization.md) - module structure, dependency rules, and feature module creation +- [`references/android-navigation.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/references/android-navigation.md) - Navigation3, adaptive navigation, large-screen quality tiers +- [`references/compose-patterns.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/references/compose-patterns.md) - Compose patterns, Material motion, animation, side effects, modifiers, stability, and migrations +- [`references/android-theming.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/references/android-theming.md) - Material 3 theming, spacing tokens, category style, colors, typography, shapes +- [`references/android-accessibility.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/references/android-accessibility.md) - accessibility, TalkBack, label copy, semantic properties, WCAG +- [`references/android-i18n.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/references/android-i18n.md) - internationalization, localization, RTL support, plurals +- [`references/android-notifications.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/references/android-notifications.md) - notifications, channels, media/PiP/sharesheet, foreground services +- [`references/android-media.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/references/android-media.md) - picking and sharing media/files (router), plus background playback (audio/video) at API 37 (Media3 `MediaSessionService`, FGS type, audio focus rules) +- [`references/android-data-sync.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/references/android-data-sync.md) - offline-first, sync strategies, conflict resolution +- [`references/kotlin-patterns.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/references/kotlin-patterns.md) - Kotlin best practices and View lifecycle interop (must-read for Kotlin code) +- [`references/coroutines-patterns.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/references/coroutines-patterns.md) - coroutines best practices and patterns +- [`references/gradle-setup.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/references/gradle-setup.md) - build logic, product flavors, BuildConfig, conventions, build files, and registering optional root tasks (for example Play Vitals reporting) +- [`references/android-ci-cd.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/references/android-ci-cd.md) - Play release AAB, tracks, signing boundaries, staged rollout, bundletool sideloads, CI release lane ordering +- [`references/testing.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/references/testing.md) - testing patterns with fakes, Hilt, Room 3, Navigation3, Compose and UIAutomator smoke, ADB device targeting, pre-release UI state checklist, Preview vs Roborazzi visual regression routing, deep links, and screenshot testing +- [`references/android-graphics.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/references/android-graphics.md) - Material Symbols icons, adaptive launcher icons, Canvas drawing, Palette API +- [`references/android-permissions.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/references/android-permissions.md) - runtime permissions and best practices +- [`references/kotlin-delegation.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/references/kotlin-delegation.md) - delegation patterns and composition guidance +- [`references/crashlytics.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/references/crashlytics.md) - crash reporting with modular provider swaps +- [`references/android-strictmode.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/references/android-strictmode.md) - StrictMode guardrails and Compose stability +- [`references/android-code-coverage.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/references/android-code-coverage.md) - JaCoCo code coverage setup and CI integration +- [`references/android-security.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/references/android-security.md) - Play Integrity (Standard/Classic), server decode and verdict policy, `requestHash`/`nonce`, errors/remediation, device trust vs local root checks, Credential Manager, pinning, encryption, Data safety +- [`references/code-quality.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/references/code-quality.md) - Detekt setup and code quality rules +- [`references/dependencies.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/references/dependencies.md) - dependency rules and version catalog guidance +- [`references/android-performance.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/references/android-performance.md) - Play Vitals thresholds, optional Play Developer Reporting API (CI/Slack), benchmarking, recomposition, app startup, splash screen +- [`references/android-debugging.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/references/android-debugging.md) - Logcat levels, ANR timeouts, LeakCanary, R8 de-obfuscation, Gradle errors, Compose recomposition +- [`references/migration.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/references/migration.md) - XML to Compose, LiveData to StateFlow, RxJava, Navigation, Accompanist, Material, Edge-to-Edge, Room 2.x → Room 3, Android 17 / API 37 checklist, [16 KB native / Play](https://github.com/Drjacky/claude-android-ninja/blob/master/references/migration.md#16-kb-memory-page-size-play-and-native-code), [Compose-XML interop hardening](https://github.com/Drjacky/claude-android-ninja/blob/master/references/migration.md#compose-xml-interop-hardening), legacy splash +- [`references/design-patterns.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/references/design-patterns.md) - Android-focused design patterns +- [`assets/proguard-rules.pro.template`](https://github.com/Drjacky/claude-android-ninja/blob/master/assets/proguard-rules.pro.template) - R8/ProGuard rules for all libraries +- [`assets/detekt.yml.template`](https://github.com/Drjacky/claude-android-ninja/blob/master/assets/detekt.yml.template) - Detekt static analysis configuration +- [`assets/libs.versions.toml.template`](https://github.com/Drjacky/claude-android-ninja/blob/master/assets/libs.versions.toml.template) - Version catalog with all dependencies +- [`assets/settings.gradle.kts.template`](https://github.com/Drjacky/claude-android-ninja/blob/master/assets/settings.gradle.kts.template) - Project settings with repositories +- [`assets/convention/`](https://github.com/Drjacky/claude-android-ninja/tree/master/assets/convention) - Gradle convention plugins, `config/` helpers, and [`QUICK_REFERENCE.md`](https://github.com/Drjacky/claude-android-ninja/blob/master/assets/convention/QUICK_REFERENCE.md) + +## Scope +This skill is focused on Android app development using: +- **Kotlin** (with coroutines, Flow, and kotlinx-datetime) +- **Jetpack Compose** (Material 3 with Material Symbols icons) +- **Material 3 Adaptive** (NavigationSuiteScaffold, adaptive pane scaffolds) +- **Navigation3** (type-safe routing) +- **Material 3** +- **Hilt** (dependency injection) +- **Room 3** (`androidx.room3`, KSP, `SQLiteDriver` / `sqlite-bundled`, Flow and `suspend` DAOs) +- **Retrofit** + **OkHttp** (networking) +- **Coil3** (image loading) +- **Firebase Crashlytics** / **Sentry** (crash reporting) +- **Macrobenchmark** / **Microbenchmark** (performance testing) +- **Detekt** + **Compose Rules** (code quality) +- **Google Truth** + **Turbine** (testing assertions) + +## Installation + +### 1. Claude Code (manual) +Clone or download this repo, then place it in Claude's skills folder and refresh skills. + +``` +~/.claude/skills/claude-android-ninja/ +├── SKILL.md +├── references/ +└── assets/ +``` + +If you prefer project-local skills, use `.claude/skills/` inside your project. + +### 2. OpenSkills CLI +[OpenSkills](https://github.com/numman-ali/openskills) can install any skill repo and generate the AGENTS/skills metadata for multiple agents. + +```bash +npx openskills install drjacky/claude-android-ninja +npx openskills sync +``` + +Global install (installs to `~/.claude/skills/`, shared across all projects): +```bash +npx openskills install drjacky/claude-android-ninja --global +``` + +Optional universal install (shared across agents): +```bash +npx openskills install drjacky/claude-android-ninja --universal +``` + +## Contributing + +### Request Missing Best Practices + +If you need a best practice topic or pattern that's missing from this SKILL, please create a feature request on GitHub. This helps us prioritize what to add next. + +[Create a Feature Request](https://github.com/drjacky/claude-android-ninja/issues/new?template=feature_request.md) + +### Report Issues + +Found a bug, outdated pattern, or incorrect guidance? Please report it so we can fix it. + +[Report a Bug](https://github.com/drjacky/claude-android-ninja/issues/new?template=bug_report.md) + +### Star History Chart + +[![Star History Chart](https://api.star-history.com/svg?repos=drjacky/claude-android-ninja&Date=&type=Date)](https://api.star-history.com/svg?repos=drjacky/claude-android-ninja&Date=&type=Date) diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/SKILL.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/SKILL.md new file mode 100644 index 000000000..f0a1736e3 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/SKILL.md @@ -0,0 +1,405 @@ +--- +name: claude-android-ninja +description: Build Android apps with Kotlin, Jetpack Compose, MVVM, Hilt, Room 3 (KSP, SQLiteDriver, Flow/suspend DAOs), and multi-module architecture. Triggers on requests to create Android projects, modules, screens, ViewModels, repositories, or Android architecture questions. Not for iOS, Flutter, React Native, KMP-only shared code without an Android app module, or backend-only APIs with no Android client. +compatibility: JDK 17+. Android Studio with Android SDK installed. Network access for Gradle dependency downloads. Version pins in assets/templates follow the repo catalog; align AGP/Kotlin/KSP with the user project before applying upgrades. +license: Apache-2.0 +metadata: + author: DrJacky + version: 1.0.0 + documentation: https://github.com/Drjacky/claude-android-ninja + tags: [android, kotlin, compose, mvvm, hilt, room, room3, datastore, paging, gradle, mobile] +--- +# Android Kotlin Compose Development + +Route tasks through the Quick Reference table and Workflow Decision Tree; open linked `references/` files only for the active task. + +## Quick Reference + +| Task | Reference File | +|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Project structure & modules | [modularization.md](references/modularization.md) | +| Architecture layers, (Domain, Data, UI, Common, ...) | [architecture.md](references/architecture.md) | +| Compose patterns, Material motion, animation, modifiers, stability | [compose-patterns.md](references/compose-patterns.md) | +| Paging 3 + Room + network (`RemoteMediator`, remote keys, `initialize`) | [compose-patterns.md](references/compose-patterns.md#offline-first-paging-and-remotemediator) | +| Accessibility, TalkBack, label copy, live regions, Espresso a11y checks | [android-accessibility.md](references/android-accessibility.md) | +| Notifications, foreground services, media-style notifications, PiP, sharesheet | [android-notifications.md](references/android-notifications.md) | +| Media: API 37 background playback (`MediaSessionService`, FGS), picking, FileProvider, sharesheet routing | [android-media.md](references/android-media.md) | +| Data sync & offline-first patterns | [android-data-sync.md](references/android-data-sync.md) | +| Material 3 theming, spacing tokens, category fit, dynamic colors | [android-theming.md](references/android-theming.md) | +| Navigation3, adaptive navigation, large-screen quality tiers | [android-navigation.md](references/android-navigation.md) | +| Kotlin patterns, View lifecycle interop | [kotlin-patterns.md](references/kotlin-patterns.md) | +| Coroutine patterns | [coroutines-patterns.md](references/coroutines-patterns.md) | +| Gradle, product flavors, BuildConfig, build performance | [gradle-setup.md](references/gradle-setup.md) | +| Play CI/CD: AAB, tracks, signing boundaries, rollout, upload automation | [android-ci-cd.md](references/android-ci-cd.md) | +| Testing approach | [testing.md](references/testing.md) | +| Pre-release UI states (empty, loading, error, offline, permissions, session loss) | [testing.md](references/testing.md#pre-release-ui-state-checklist) | +| Internationalization & localization | [android-i18n.md](references/android-i18n.md) | +| Icons, adaptive launcher specs, custom drawing | [android-graphics.md](references/android-graphics.md) | +| Runtime permissions | [android-permissions.md](references/android-permissions.md) | +| Kotlin delegation patterns | [kotlin-delegation.md](references/kotlin-delegation.md) | +| Crash reporting | [crashlytics.md](references/crashlytics.md) | +| StrictMode guardrails | [android-strictmode.md](references/android-strictmode.md) | +| Multi-module dependencies | [dependencies.md](references/dependencies.md) | +| Code quality (Detekt) | [code-quality.md](references/code-quality.md) | +| Code coverage (JaCoCo) | [android-code-coverage.md](references/android-code-coverage.md) | +| Security, Play Integrity (Standard/Classic), server decode, `requestHash`/`nonce`, tiered policy, remediation; Credential Manager; local root checks as supplementary | [android-security.md](references/android-security.md) | +| Design patterns | [design-patterns.md](references/design-patterns.md) | +| Performance, Play Vitals, Play Developer Reporting API (CI vitals), startup, recomposition, jank, battery, Perfetto / system traces | [android-performance.md](references/android-performance.md) | +| Debugging, Logcat levels, ANR, Gradle error patterns, R8, memory leaks | [android-debugging.md](references/android-debugging.md) | +| ADB device targeting, install or launch smoke, UIAutomator black-box checks | [testing.md](references/testing.md#agent-automation-adb-and-uiautomator) | +| Migration guides (XML, RxJava, Navigation, Compose, Room 2→3, Android 17 / API 37, 16 KB native, Compose-XML interop) | [migration.md](references/migration.md); [16 KB page size](references/migration.md#16-kb-memory-page-size-play-and-native-code); [Compose-XML interop](references/migration.md#compose-xml-interop-hardening) | + +## Examples + +**Greenfield Android app with convention plugins** + +User goal: new repo matching the skill stack. + +Actions: copy `assets/settings.gradle.kts.template`, `assets/libs.versions.toml.template`, `assets/convention/` into `build-logic/` per `assets/convention/QUICK_REFERENCE.md`; wire `includeBuild("build-logic")`; read [modularization.md](references/modularization.md) and [gradle-setup.md](references/gradle-setup.md). + +Result: root + `app` + core modules with version catalog and convention plugins applied. + +**New feature screen (Compose + ViewModel)** + +User goal: one new flow in a feature module. + +Actions: [modularization.md](references/modularization.md) for module naming and dependency direction; [compose-patterns.md](references/compose-patterns.md) for Screen, state, effects; [kotlin-patterns.md](references/kotlin-patterns.md) + [coroutines-patterns.md](references/coroutines-patterns.md) for `StateFlow` / events; [architecture.md](references/architecture.md) for domain vs data boundaries. + +Result: feature module with Screen composable, ViewModel, `UiState`, and DI aligned to existing graphs. + +**Offline-first list with Room 3 and remote API** + +User goal: cached list + network refresh. + +Actions: [compose-patterns.md](references/compose-patterns.md#offline-first-paging-and-remotemediator) for Paging 3 + `RemoteMediator`; [architecture.md](references/architecture.md) for repository placement; Room 3 + `SQLiteDriver` per Workflow Decision Tree database bullets and [migration.md](references/migration.md#room-2x-to-room-3) if upgrading. + +Result: single source of truth in Room, UI driven by `PagingData` or equivalent pattern from the guide. + +**Target SDK / compile SDK bump (e.g. API 37)** + +User goal: migrate toolchain and platform requirements. + +Actions: walk [migration.md](references/migration.md#android-17-api-37-migration); pin AGP/Kotlin/KSP using [gradle-setup.md](references/gradle-setup.md) and [dependencies.md](references/dependencies.md); cross-check edge-to-edge, media, security sections linked from the Workflow Decision Tree for API 37. + +Result: `compileSdk` / `targetSdk` raised with manifest, Gradle, and feature code adjusted per the migration doc. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|----------------------------------------------------------------------|------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Gradle sync fails, plugin not found, or version catalog errors | Missing `google()` / `mavenCentral()`, wrong plugin id, or catalog alias drift | [gradle-setup.md](references/gradle-setup.md) for repositories, plugins, and catalog wiring; align with `assets/libs.versions.toml.template` when bootstrapping | +| KSP errors on Room, or Room 3 builder rejects missing driver | Room 3 expects `setDriver(BundledSQLiteDriver())` (or project equivalent) per convention | [migration.md](references/migration.md#room-2x-to-room-3); module layout in [modularization.md](references/modularization.md); DAO patterns in [architecture.md](references/architecture.md) | +| Compose runtime warnings about unstable / skippable recompositions | Unstable parameter types or state held incorrectly | [compose-patterns.md](references/compose-patterns.md) stability sections; [android-performance.md](references/android-performance.md) Compose recomposition; [kotlin-patterns.md](references/kotlin-patterns.md) for immutable models | +| Release build crashes, `ClassNotFoundException`, or missing R8 rules | Shrinking removed reflective or JNI entry points | [gradle-setup.md](references/gradle-setup.md) R8 keep-rules audit; [android-debugging.md](references/android-debugging.md) for stack traces and mapping files | +| ANR or jank claims without evidence | Main-thread or measurement assumptions | [android-performance.md](references/android-performance.md#perfetto-system-traces) before changing architecture; [android-debugging.md](references/android-debugging.md) for ANR traces | + +## Workflow Decision Tree + +**Creating a new project?** +→ Start with `assets/settings.gradle.kts.template` for settings and module includes +→ Start with `assets/libs.versions.toml.template` for the version catalog +→ Copy all files from `assets/convention/` to `build-logic/convention/src/main/kotlin/` +→ Create `build-logic/settings.gradle.kts` (see `assets/convention/QUICK_REFERENCE.md`) +→ Add `includeBuild("build-logic")` to root `settings.gradle.kts` +→ Add plugin entries to `gradle/libs.versions.toml` (see `assets/convention/QUICK_REFERENCE.md`) +→ Copy `assets/proguard-rules.pro.template` to `app/proguard-rules.pro` +→ Read [modularization.md](references/modularization.md) for structure and module types +→ Use [gradle-setup.md](references/gradle-setup.md) for build files and build logic + +**Configuring Gradle/build files?** +→ Use [gradle-setup.md](references/gradle-setup.md) for module `build.gradle.kts` patterns +→ Use [gradle-setup.md](references/gradle-setup.md) → "Build Performance" for optimization workflow, diagnostics, and bottleneck troubleshooting +→ Copy convention plugins from `assets/convention/` to `build-logic/` in your project +→ See `assets/convention/QUICK_REFERENCE.md` for setup instructions and examples +→ Copy `assets/proguard-rules.pro.template` to `app/proguard-rules.pro` for R8 rules + +**Setting up code quality / Detekt?** +→ Use [code-quality.md](references/code-quality.md) for Detekt convention plugin setup +→ Start from `assets/detekt.yml.template` for rules and enable Compose rules + +**Adding or updating dependencies?** +→ Follow [dependencies.md](references/dependencies.md) +→ Update `assets/libs.versions.toml.template` if the dependency is missing + +**Adding a new feature/module?** +→ Follow module naming in [modularization.md](references/modularization.md) +→ Implement Presentation in the feature module +→ Follow dependency flow: Feature → Core/Domain → Core/Data + +**Building UI screens/components?** +→ Read [compose-patterns.md](references/compose-patterns.md) for screen architecture, state, components, modifiers +→ Use [compose-patterns.md](references/compose-patterns.md) -> State Management -> "Loading and refresh UX" for stable layout during loads and refreshes (avoid full-screen spinners that wipe context) +→ Use [android-theming.md](references/android-theming.md) for Material 3 colors, typography, and shapes +→ **Always** align Kotlin code with [kotlin-patterns.md](references/kotlin-patterns.md) +→ Create Screen + ViewModel + UiState in the feature module +→ Use shared components from `core/ui` when possible + +**Handling State and Events?** +→ Use `StateFlow` for state; `Channel` + `receiveAsFlow()` for strict one-shot UI commands; `SharedFlow` for multicast or replay-intended events (see [coroutines-patterns.md](references/coroutines-patterns.md)) +→ Survive process death with `SavedStateHandle` (see [compose-patterns.md](references/compose-patterns.md)) + +**Setting up app theme (colors, typography, shapes)?** +→ Follow [android-theming.md](references/android-theming.md) for Material 3 theming and dynamic colors +→ Use semantic color roles from `MaterialTheme.colorScheme` (never hardcoded colors); pair every fill with its `on*` partner - see [Color Pairing Rules](references/android-theming.md#color-pairing-rules) +→ Declare the **full** M3 color set in `Color.kt` (surface containers, dim/bright, `*Fixed`/`*FixedDim`) so dynamic color and contrast variants stay consistent - see [Full Color Role Reference](references/android-theming.md#full-color-role-reference-m3) and [Surface Container Hierarchy](references/android-theming.md#surface-container-hierarchy) +→ Express depth via container tone first, shadows only for components that float over arbitrary content - see [Tonal Elevation vs Shadows](references/android-theming.md#tonal-elevation-vs-shadows) +→ Use `outline` for interactive borders/focus, `outlineVariant` for decorative dividers - see [`outline` vs `outlineVariant`](references/android-theming.md#outline-vs-outlinevariant) +→ Support light/dark themes with user preference toggle +→ Enable dynamic color (Material You) for API 31+, harmonize brand/extended colors against `primary` - see [Brand Color Harmonization](references/android-theming.md#brand-color-harmonization) +→ Honor the system contrast slider on Android 14+ (API 34) by shipping Medium/High-contrast scheme variants and reading `UiModeManager.getContrast()` - see [User Contrast Preference](references/android-theming.md#user-contrast-preference-android-14) +→ For region-local palette overrides (destructive scopes, on-media toolbars), use a nested `MaterialTheme` with `colorScheme.copy(...)` - see [Scoped Themes](references/android-theming.md#scoped-themes) +→ Pick `Card` / `OutlinedCard` / `ElevatedCard` by surface separation, not importance, and override shapes at the **token** level - see [Card Variants](references/compose-patterns.md#card-variants-filled--outlined--elevated) and [Component Shape Defaults](references/compose-patterns.md#component-shape-defaults) + +**Writing any Kotlin code?** +→ **Always** follow [kotlin-patterns.md](references/kotlin-patterns.md) +→ Ensure practices align with [architecture.md](references/architecture.md), [modularization.md](references/modularization.md), and [compose-patterns.md](references/compose-patterns.md) + +**Setting up data/domain layers?** +→ Read [architecture.md](references/architecture.md) +→ Hilt `@Binds`, scopes, and DI anti-patterns: [architecture.md](references/architecture.md) -> Domain Layer -> "Dependency Injection Setup" +→ Create Repository interfaces in `core/domain` +→ Create implementations in `core/data` using Room 3/Retrofit/DataStore +→ Use DataStore for simple key-value pairs, Room 3 for complex relational data (`suspend` / `Flow` DAOs, `SQLiteDriver`) + +**Implementing Lists and Scrolling?** +→ Use `LazyColumn`/`LazyRow` with stable keys and `contentType` (see [compose-patterns.md](references/compose-patterns.md)) +→ For large datasets, use Paging 3 (see [compose-patterns.md](references/compose-patterns.md) -> "Paging 3") +→ For Room-backed grids with a remote API, use `RemoteMediator` ([compose-patterns.md](references/compose-patterns.md#offline-first-paging-and-remotemediator)) + +**Handling Navigation?** +→ Use Navigation3 for adaptive navigation (see [android-navigation.md](references/android-navigation.md)) +→ Avoid navigation anti-patterns (see [android-navigation.md](references/android-navigation.md) -> "Navigation Anti-Patterns") + +**Optimizing Performance?** +→ Follow the Performance Checklist in [android-performance.md](references/android-performance.md) +→ If the user asks for **automated Play Console vitals** (CI/Slack, no Play Console UI), use [android-performance.md](references/android-performance.md) → **Optional: Play Vitals observability (Play Developer Reporting API)** +→ Use `BasicTextField2` for high-frequency text input + +**Auditing battery drain or stuck wake locks?** +→ Use [android-performance.md](references/android-performance.md) → "Excessive partial wake locks (Play Vitals core metric)" for the threshold (>2 hr cumulative non-exempt per session, >5% of sessions over 28 days, enforced March 2026), the use-case-to-substitute matrix, sensor batching, and stuck-worker diagnosis +→ Required: UIDT API for user-initiated transfers; WorkManager + `WorkInfo.stopReason` for syncs; manual wake lock acquired only **after** packet arrival on sockets +→ Forbidden: a manual wake lock alongside `FusedLocationProviderClient` callbacks, `MediaSessionService` audio, or any system API that already wakes the CPU + +**Testing?** +→ Read [testing.md](references/testing.md) for testing philosophy and patterns +→ Use Turbine for testing Flow emissions (see [testing.md](references/testing.md) -> "Testing Flow Emissions with Turbine") +→ Create Repository interfaces in `core/domain` +→ Implement Repository in `core/data` +→ Create DataSource + DAO in `core/data` + +**Implementing offline-first or data synchronization?** +→ Follow [android-data-sync.md](references/android-data-sync.md) for sync strategies, conflict resolution, and cache invalidation +→ Use Room 3 as single source of truth with sync metadata (syncStatus, lastModified) +→ Schedule background sync with WorkManager +→ Monitor network state before syncing + +**Setting up navigation?** +→ Follow [android-navigation.md](references/android-navigation.md) for Navigation3 architecture, state management, and adaptive navigation +→ See [modularization.md](references/modularization.md) for feature module navigation components (Destination, Navigator, Graph) +→ Configure navigation graph in the app module +→ Use feature navigation destinations and navigator interfaces + +**Setting up deep links, App Links, Digital Asset Links, verification, Dynamic App Links, or custom schemes?** +→ Read [android-navigation.md](references/android-navigation.md) → "Deep Links" for `NavKey` parsing, synthetic back stack, manifest filters, `assetlinks.json`, verification, `DomainVerificationManager`, Dynamic App Links (API 35), custom schemes, troubleshooting +→ Use [testing.md](references/testing.md) → "Testing Deep Links" for `am start`, `pm set-app-links` / `pm verify-app-links --re-verify` / `pm get-app-links` / `dumpsys package d`, Digital Asset Links REST (append `return_relation_extensions=true` for dynamic rules), custom-scheme launch semantics, and instrumented `onNewIntent` tests +→ Required: Play Console → Release → Setup → App signing → uppercase SHA-256 in `assetlinks.json`; deep-link Activity `android:exported="true"`, `android:launchMode="singleTask"`, `onNewIntent` + `setIntent`; `android:autoVerify="true"` only on HTTPS intent-filters +→ Forbidden: security-critical flows on custom URI schemes - use HTTPS App Links + +**Adding tests?** +→ Use [testing.md](references/testing.md) for patterns and examples +→ Use [testing.md](references/testing.md#pre-release-ui-state-checklist) for empty, loading, error, offline, permission-denied, and session-loss routing before tightening coverage +→ Use [testing.md](references/testing.md#preview-screenshot-testing-vs-roborazzi) when choosing Compose Preview Screenshot Testing vs Roborazzi for visual regression +→ Use [testing.md](references/testing.md) → "Screenshot Testing" for Compose Preview Screenshot Testing setup +→ Keep test doubles in `core/testing` + +**Handling runtime permissions?** +→ Follow [android-permissions.md](references/android-permissions.md) for manifest declarations and Compose permission patterns +→ Request permissions contextually and handle "Don't ask again" flows +→ For Photo Picker, document contracts, FileProvider, URI grants, and sharesheet routing, use [android-media.md](references/android-media.md#picking-media-and-documents) and [android-media.md → Sharing media and files](references/android-media.md#sharing-media-and-files) + +**Showing notifications or foreground services?** +→ Use [android-notifications.md](references/android-notifications.md) for notification channels, styles, actions, and foreground services +→ Check POST_NOTIFICATIONS permission on API 33+ before showing notifications +→ Create notification channels at app startup (required for API 26+) + +**Playing audio or video in the background (target SDK 37)?** +→ Use [android-media.md](references/android-media.md) → "Background media playback hardening (API 37)" for `MediaSessionService`, `mediaPlayback` foreground service type, and `MediaSession` lifecycle +→ Required: declare `FOREGROUND_SERVICE_MEDIA_PLAYBACK` and `android:foregroundServiceType="mediaPlayback"`; build a `MediaSession` around a Media3 `Player`; release session and player in `onDestroy()`; stop the service on `Player.STATE_ENDED` +→ Forbidden: standalone `MediaPlayer` / `AudioTrack` / raw `ExoPlayer` background playback without a `MediaSession`; `requestAudioFocus()` from a service with no session; manual wake locks alongside `MediaSessionService` + +**Sharing logic across ViewModels or avoiding base classes?** +→ Use delegation via interfaces as described in [kotlin-delegation.md](references/kotlin-delegation.md) +→ Prefer small, injected delegates for validation, analytics, or feature flags + +**Adding crash reporting / monitoring?** +→ Follow [crashlytics.md](references/crashlytics.md) for provider-agnostic interfaces and module placement +→ Use DI bindings to swap between Firebase Crashlytics or Sentry + +**Enabling StrictMode guardrails?** +→ Follow [android-strictmode.md](references/android-strictmode.md) for app-level setup and Compose compiler diagnostics +→ Use Sentry/Firebase init from [crashlytics.md](references/crashlytics.md) to ship StrictMode logs + +**Choosing design patterns for a new feature, business logic, or system?** +→ Use [design-patterns.md](references/design-patterns.md) for Android-focused pattern guidance +→ Align with [architecture.md](references/architecture.md) and [modularization.md](references/modularization.md) + +**Measuring performance regressions or startup/jank?** +→ Use [android-performance.md](references/android-performance.md) for Macrobenchmark, Baseline Profiles, and ProfileInstaller setup +→ Keep benchmark module aligned with `benchmark` build type in [gradle-setup.md](references/gradle-setup.md) +→ If the user explicitly requests to investigate jank or add custom trace points, use [android-performance.md](references/android-performance.md) for System Tracing (`androidx.tracing`) setup +→ For trace-backed debugging rules (what to require from the user, what not to infer without artifacts), use [android-performance.md](references/android-performance.md#perfetto-system-traces) + +**Setting up app initialization or splash screen?** +→ Follow [android-performance.md](references/android-performance.md) → "App Startup & Initialization" for App Startup library, lazy init, and splash screen +→ Avoid ContentProvider-based auto-initialization - use `Initializer` interface instead +→ Use `installSplashScreen()` with `setKeepOnScreenCondition` for loading state +→ Migrate `windowBackground`-only splash, dedicated `SplashActivity`, or Android 12+ double-splash issues via [migration.md](references/migration.md) → **Legacy splash to Splash Screen API** + +**Adding icons, images, or custom graphics?** +→ Use [android-graphics.md](references/android-graphics.md) for Material Symbols icons and custom drawing +→ Download icons via Iconify API or Google Fonts (avoid deprecated `Icons.Default.*` library) +→ Use `Modifier.drawWithContent`, `drawBehind`, or `drawWithCache` for custom graphics + +**Creating custom UI effects (glow, shadows, gradients)?** +→ Check [android-graphics.md](references/android-graphics.md) for Canvas drawing, BlendMode, and Palette API patterns +→ Use `rememberInfiniteTransition` for animated effects + +**Ensuring accessibility compliance (TalkBack, touch targets, color contrast)?** +→ Follow [android-accessibility.md](references/android-accessibility.md) for semantic properties and WCAG guidelines +→ Provide `contentDescription` for all icons and images +→ Ensure 48dp × 48dp minimum touch targets +→ Test with TalkBack and Accessibility Scanner + +**Working with images and color extraction?** +→ Use [android-graphics.md](references/android-graphics.md) → "Image Loading with Coil3" for AsyncImage, SubcomposeAsyncImage, rememberAsyncImagePainter, and Hilt ImageLoader setup +→ Use [android-graphics.md](references/android-graphics.md) for Palette API and color extraction + +**Implementing complex coroutine flows or background work?** +→ Follow [coroutines-patterns.md](references/coroutines-patterns.md) for structured concurrency patterns +→ Use appropriate dispatchers (IO, Default, Main) and proper cancellation handling +→ Prefer `StateFlow` (and `SharedFlow` where appropriate) over `Channel` for observable **state**; use `Channel` for one-shot commands as in [coroutines-patterns.md](references/coroutines-patterns.md) +→ Use `callbackFlow` to wrap Android callback APIs (connectivity, sensors, location) into Flow +→ Use `suspendCancellableCoroutine` for one-shot callbacks (Play Services tasks, biometrics) +→ Use `combine()` to merge multiple Flows in ViewModels, `shareIn` to share expensive upstream +→ Handle backpressure with `buffer`, `conflate`, `debounce`, or `sample` + +**Need to share behavior across multiple classes?** +→ Use [kotlin-delegation.md](references/kotlin-delegation.md) for interface delegation patterns +→ Avoid base classes; prefer composition with delegated interfaces +→ Examples: Analytics, FormValidator, CrashReporter + +**Refactoring existing code or improving architecture?** +→ Review [architecture.md](references/architecture.md) for layer responsibilities +→ Read [architecture.md](references/architecture.md) -> "Cross-cutting anti-patterns (quick reference)" for common layering mistakes +→ Check [design-patterns.md](references/design-patterns.md) for applicable patterns +→ Follow [kotlin-patterns.md](references/kotlin-patterns.md) for Kotlin-specific improvements +→ Ensure compliance with [modularization.md](references/modularization.md) dependency rules + +**Debugging crashes, ANRs, or obfuscated stack traces?** +→ Follow [android-debugging.md](references/android-debugging.md) for Logcat, ANR traces, and Compose recomposition debugging +→ Use [android-debugging.md](references/android-debugging.md) for R8 mapping files and manual de-obfuscation + +**Proposing install, cold start, or black-box smoke driven by ADB or UIAutomator?** +→ Use [testing.md](references/testing.md#agent-automation-adb-and-uiautomator) for device targeting, `am start`, logcat smoke, and instrumented UIAutomator skeletons +→ Use [testing.md](references/testing.md#testing-deep-links) for `am start` deep-link matrices and `pm verify-app-links` when the task is link verification, not generic launch + +**Auditing R8 keep rules / fixing release size or release-only crashes?** +→ Use [gradle-setup.md](references/gradle-setup.md) → "R8 Keep-Rules Audit" for the redundant-library list, impact hierarchy, subsuming-rule detection, reflection-narrowing playbook, and AGP 9 default-optimization re-audit + +**Going edge-to-edge / fixing IME, insets, or system-bar bugs?** +→ Use [compose-patterns.md](references/compose-patterns.md) → "Edge-to-Edge (Mandatory on API 36)" for IME insets (`fitInside(WindowInsetsRulers.Ime.current)` vs `imePadding()` ordering and double-padding pitfalls), system-bar appearance/contrast (`isAppearanceLight*Bars`, `isNavigationBarContrastEnforced`), `NavigationSuiteScaffold` / pane-scaffold inset handling, full-screen `Dialog` `decorFitsSystemWindows`, `StatusBarProtection` scrim, and the per-Activity edge-to-edge checklist +→ At target SDK 37, add IME visibility-after-rotation handling in the same guide's `#### IME (soft keyboard) insets` block +→ Manifest must set `android:windowSoftInputMode="adjustResize"` for any Activity hosting text input + +**Debugging performance issues or memory leaks?** +→ Enable [android-strictmode.md](references/android-strictmode.md) for development builds +→ Use [android-performance.md](references/android-performance.md) for profiling and benchmarking +→ For ANR, jank, or main-thread claims without measurements, follow [android-performance.md](references/android-performance.md#perfetto-system-traces) before concluding cause +→ Use [android-debugging.md](references/android-debugging.md) for LeakCanary and heap dump analysis +→ Check [coroutines-patterns.md](references/coroutines-patterns.md) for coroutine cancellation patterns + +**Setting up CI/CD or code quality checks?** +→ Use [android-ci-cd.md](references/android-ci-cd.md) for Play-bound AAB, tracks, signing boundaries, staged rollout, and upload automation routing +→ Use [code-quality.md](references/code-quality.md) for Detekt baseline and CI integration +→ Use [gradle-setup.md](references/gradle-setup.md) for build cache and convention plugins +→ Use [testing.md](references/testing.md) for test organization and coverage + +**Handling sensitive data or privacy concerns?** +→ Follow [crashlytics.md](references/crashlytics.md) for data scrubbing patterns +→ Use [android-permissions.md](references/android-permissions.md) for proper permission justification +→ Check [android-strictmode.md](references/android-strictmode.md) for detecting cleartext network traffic + +**Migrating legacy code (LiveData, Fragments, Accompanist, RxJava, Room 2.x)?** +→ Use [migration.md](references/migration.md) for all migration paths (including [Room 2.x → Room 3](references/migration.md#room-2x-to-room-3)) +→ Use [migration.md → Compose-XML interop (hardening)](references/migration.md#compose-xml-interop-hardening) when `ComposeView` / `AndroidView` share a screen with XML or focus-sensitive Views +→ Follow [architecture.md](references/architecture.md) for MVVM patterns + +**Migrating to target SDK 37 (Android 17)?** +→ Walk [migration.md → Android 17 (API 37) Migration](references/migration.md#android-17-api-37-migration) top to bottom, then open each cross-link inside that section for full rules +→ Ship JNI or bundled `.so` files: align ELF segments and Play 16 KB page-size checks per [migration.md → 16 KB memory page size](references/migration.md#16-kb-memory-page-size-play-and-native-code) +→ Required: catalog `compileSdk` / `targetSdk` 37; pin `agp`, Gradle wrapper, `kotlin`, and `ksp` only after `./gradlew help` succeeds per [gradle-setup.md](references/gradle-setup.md) and [dependencies.md](references/dependencies.md); cleartext, loopback, CT, and explicit URI grants per [android-security.md](references/android-security.md); adaptive large-screen layouts, `adjustResize` on the launcher Activity, and IME-after-rotation per [compose-patterns.md](references/compose-patterns.md); background audio/video via [android-media.md](references/android-media.md); Robolectric rules per [testing.md → Robolectric and SDK 37 (Android 17)](references/testing.md#robolectric-and-sdk-37-android-17) **only** when JVM tests use `RobolectricTestRunner` +→ Forbidden: production-wide cleartext without domain-scoped Network Security Config; cross-process loopback without the API 37 permission where the platform requires it; background `MediaPlayer` / `AudioTrack` / raw `ExoPlayer` without Media3 `MediaSessionService` + `mediaPlayback` FGS + `MediaSession`; Robolectric releases older than 4.13 on current JDKs; `ACTION_SEND` (and similar) intents that attach `content` URIs without explicit `FLAG_GRANT_READ_URI_PERMISSION` or `FLAG_GRANT_WRITE_URI_PERMISSION` +→ `com.android.tools.build:gradle` HTTP 404: catalog `agp` is not published on `google()` yet; pick a published AGP that supports `compileSdk` 37 per [gradle-setup.md → AGP version pin](references/gradle-setup.md#agp-version-pin-resolve-before-merge) +→ `MissingValueException` / unresolved providers on `compile*JavaWithJavac`: isolate JaCoCo Tier 2 (`ScopedArtifacts` combined report) per [android-code-coverage.md](references/android-code-coverage.md) before chasing Kotlin or KSP bumps + +**Adding Compose animations?** +→ Use [compose-patterns.md](references/compose-patterns.md) → "Animation" for `AnimatedVisibility`, `AnimatedContent`, `animate*AsState`, `Animatable`, shared elements +→ Use `graphicsLayer` for GPU-accelerated transforms (no recomposition) +→ Always provide `label` parameter for Layout Inspector debugging + +**Using side effects (LaunchedEffect, DisposableEffect)?** +→ Use [compose-patterns.md](references/compose-patterns.md) → "Side Effects" for effect selection guide +→ `LaunchedEffect(key)` for state-driven coroutines, `rememberCoroutineScope` for event-driven +→ `DisposableEffect` for listener/resource cleanup, always include `onDispose` +→ `LifecycleResumeEffect` for onResume/onPause work (camera, media), `LifecycleStartEffect` for onStart/onStop (location, sensors) + +**Working with Modifier ordering or custom modifiers?** +→ Use [compose-patterns.md](references/compose-patterns.md) → "Modifiers" for chain ordering rules and patterns +→ Use `Modifier.Node` for custom modifiers (not deprecated `Modifier.composed`) +→ Order: size → padding → drawing → interaction + +**Migrating from Accompanist or deprecated Compose APIs?** +→ Use [migration.md](references/migration.md) for Accompanist, Compose API, Material, Edge-to-Edge, and Room upgrades +→ See [compose-patterns.md](references/compose-patterns.md) → "Deprecated Patterns & Migrations" for a summary list + +**Optimizing Compose recomposition or stability?** +→ Use [compose-patterns.md](references/compose-patterns.md) for `@Immutable`/`@Stable` annotations +→ Use [android-performance.md](references/android-performance.md) → "Compose Recomposition Performance" for three phases, deferred state reads, Strong Skipping Mode +→ Check [gradle-setup.md](references/gradle-setup.md) for Compose Compiler metrics and stability reports +→ Use [kotlin-patterns.md](references/kotlin-patterns.md) for immutable data structures + +**Working with databases (Room 3)?** +→ Define DAOs and entities in `core/database` per [modularization.md](references/modularization.md); use **`androidx.room3`**, KSP, and **`setDriver(BundledSQLiteDriver())`** on the builder (see `app.android.room` convention) +→ Use [testing.md](references/testing.md) for in-memory database testing and Room 3 migration tests +→ Follow [architecture.md](references/architecture.md) for repository patterns +→ Upgrading from Room 2.x: [migration.md → Room 2.x to Room 3](references/migration.md#room-2x-to-room-3) + +**Need internationalization/localization (i18n/l10n)?** +→ Use [android-i18n.md](references/android-i18n.md) for string resources, plurals, and RTL support +→ Follow [compose-patterns.md](references/compose-patterns.md) for RTL-aware Compose layouts +→ Use [testing.md](references/testing.md) for locale-specific testing + +**Implementing network calls (Retrofit)?** +→ Use [architecture.md](references/architecture.md) → "Network Layer Setup" for Retrofit service interfaces, Hilt NetworkModule, and AuthInterceptor +→ Define API interfaces in `core/network` per [modularization.md](references/modularization.md) +→ Follow [dependencies.md](references/dependencies.md) for Retrofit, OkHttp, and serialization setup +→ Handle errors with generic `Result` from [kotlin-patterns.md](references/kotlin-patterns.md) + +**Creating custom lint rules or code checks?** +→ Use [code-quality.md](references/code-quality.md) for Detekt custom rules +→ Follow [gradle-setup.md](references/gradle-setup.md) for convention plugin setup +→ Check [android-strictmode.md](references/android-strictmode.md) for runtime checks + +**Need code coverage reporting?** +→ Use [android-code-coverage.md](references/android-code-coverage.md) for JaCoCo setup +→ Follow [testing.md](references/testing.md) for test strategies +→ Check [gradle-setup.md](references/gradle-setup.md) for convention plugin integration + +**Implementing security features (encryption, biometrics, pinning)?** +→ Use [android-security.md](references/android-security.md) for comprehensive security guide +→ Follow [android-permissions.md](references/android-permissions.md) for runtime permissions +→ Check [crashlytics.md](references/crashlytics.md) for PII scrubbing and data privacy + +**Implementing fraud-resistant or high-value flows (payments, session bootstrap, integrity-gated APIs)?** +→ Read [android-security.md](references/android-security.md): **Device trust and abuse resistance**, **Play Integrity API** (prerequisites, Standard vs Classic, server checklist, errors, remediation), **Root and Emulator Detection** (how this fits next to Play Integrity), **Security Checklist** +→ If Cloud Console / Play Console enablement or the **Google Cloud project number** is missing, list the missing prerequisites (see that guide) and stop before wiring client code diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidApplicationBaselineProfileConventionPlugin.kt b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidApplicationBaselineProfileConventionPlugin.kt new file mode 100644 index 000000000..37f7cfd69 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidApplicationBaselineProfileConventionPlugin.kt @@ -0,0 +1,30 @@ +/* + * Convention plugin for baseline profile generation + * Configures: Baseline profile plugin for performance optimization + * Applies to: App module + */ + +import com.android.build.api.dsl.ApplicationExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies + +class AndroidApplicationBaselineProfileConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + apply(plugin = "androidx.baselineprofile") + + extensions.configure { + // Baseline profile configuration is handled by the plugin + // Just ensure we have the dependency + } + + dependencies { + // Reference to baselineprofile module (if exists) + // add("baselineProfile", project(":baselineprofile")) + } + } + } +} diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidApplicationComposeConventionPlugin.kt b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidApplicationComposeConventionPlugin.kt new file mode 100644 index 000000000..be3f85c9d --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidApplicationComposeConventionPlugin.kt @@ -0,0 +1,22 @@ +/* + * Convention plugin for Android application with Compose + * Applies: Compose compiler plugin and configures Compose options + * Requires: `app.android.application` (or equivalent) already applied so `com.android.application` runs exactly once. + */ + +import com.android.build.api.dsl.ApplicationExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.getByType + +class AndroidApplicationComposeConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + apply(plugin = "org.jetbrains.kotlin.plugin.compose") + + val extension = extensions.getByType() + configureAndroidCompose(extension) + } + } +} diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidApplicationConventionPlugin.kt b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidApplicationConventionPlugin.kt new file mode 100644 index 000000000..48ce6dd90 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidApplicationConventionPlugin.kt @@ -0,0 +1,40 @@ +/* + * Convention plugin for Android application modules + * Configures: Android, Lint, Dependency Guard + * Note: AGP 9+ has built-in Kotlin support, no need for kotlin-android plugin + */ + +import com.android.build.api.dsl.ApplicationExtension +import com.android.build.api.variant.ApplicationAndroidComponentsExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.configure + +class AndroidApplicationConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + apply(plugin = "com.android.application") + apply(plugin = "app.android.lint") + + extensions.configure { + configureKotlinAndroid(this) + + defaultConfig { + targetSdk = libs.findVersion("targetSdk").get().toString().toInt() + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + testOptions { + animationsDisabled = true + } + + configureGradleManagedDevices(this) + } + + extensions.configure { + configurePrintApksTask(this) + } + } + } +} diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidApplicationJacocoConventionPlugin.kt b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidApplicationJacocoConventionPlugin.kt new file mode 100644 index 000000000..f02ac28b9 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidApplicationJacocoConventionPlugin.kt @@ -0,0 +1,25 @@ +/* + * Convention plugin for JaCoCo code coverage on Android application modules + * Configures: JaCoCo plugin, coverage reports (XML + HTML), exclusions + * Applies to: :app module when code coverage is needed + */ + +import com.android.build.api.dsl.ApplicationExtension +import com.android.build.api.variant.ApplicationAndroidComponentsExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.getByType +import org.gradle.testing.jacoco.plugins.JacocoPlugin + +class AndroidApplicationJacocoConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + apply() + configureJacoco( + commonExtension = extensions.getByType(), + androidComponentsExtension = extensions.getByType(), + ) + } + } +} diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidFeatureConventionPlugin.kt b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidFeatureConventionPlugin.kt new file mode 100644 index 000000000..e0a38547f --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidFeatureConventionPlugin.kt @@ -0,0 +1,50 @@ +/* + * Convention plugin for feature implementation modules + * Configures: Feature module with UI, ViewModel, Hilt, Navigation3 + * Applies to: feature/:feature-name modules + */ + +import com.android.build.api.dsl.LibraryExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies + +class AndroidFeatureConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + apply(plugin = "app.android.library") + apply(plugin = "app.android.library.compose") + apply(plugin = "app.hilt") + + extensions.configure { + testOptions { + animationsDisabled = true + } + configureGradleManagedDevices(this) + } + + dependencies { + // Core dependencies + add("implementation", project(":core:ui")) + add("implementation", project(":core:domain")) + add("implementation", project(":core:data")) + + // Lifecycle + add("implementation", libs.findLibrary("androidx.lifecycle.runtime.compose").get()) + add("implementation", libs.findLibrary("androidx.lifecycle.viewmodel.compose").get()) + + // Navigation3 + add("implementation", libs.findLibrary("androidx.navigation3.runtime").get()) + add("implementation", libs.findLibrary("androidx.navigation3.compose").get()) + + // Adaptive layouts (NavigationSuiteScaffold, ListDetailPaneScaffold, SupportingPaneScaffold) + libs.findBundle("adaptive").ifPresent { add("implementation", it) } + + // Testing + add("androidTestImplementation", libs.findLibrary("androidx.lifecycle.runtime.compose").get()) + } + } + } +} diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidLibraryComposeConventionPlugin.kt b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidLibraryComposeConventionPlugin.kt new file mode 100644 index 000000000..45041b5bc --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidLibraryComposeConventionPlugin.kt @@ -0,0 +1,22 @@ +/* + * Convention plugin for Android library with Compose + * Applies: Compose compiler plugin and configures Compose options + * Requires: `app.android.library` (or `app.android.feature`) already applied so `com.android.library` runs exactly once. + */ + +import com.android.build.api.dsl.LibraryExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.getByType + +class AndroidLibraryComposeConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + apply(plugin = "org.jetbrains.kotlin.plugin.compose") + + val extension = extensions.getByType() + configureAndroidCompose(extension) + } + } +} diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidLibraryConventionPlugin.kt b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidLibraryConventionPlugin.kt new file mode 100644 index 000000000..0cd7477cd --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidLibraryConventionPlugin.kt @@ -0,0 +1,59 @@ +/* + * Convention plugin for Android library modules + * Configures: Android, Lint, Testing + * Note: AGP 9+ has built-in Kotlin support, no need for kotlin-android plugin + */ + +import com.android.build.api.dsl.LibraryExtension +import com.android.build.api.variant.LibraryAndroidComponentsExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies + +class AndroidLibraryConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + apply(plugin = "com.android.library") + apply(plugin = "app.android.lint") + + extensions.configure { + configureKotlinAndroid(this) + + defaultConfig { + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + // Version catalog entries for targetSdk + testOptions.targetSdk = libs.findVersion("targetSdk").get().toString().toInt() + lint.targetSdk = libs.findVersion("targetSdk").get().toString().toInt() + } + + testOptions { + animationsDisabled = true + } + + configureGradleManagedDevices(this) + + // Resource prefix based on module path + // :core:data → core_data_ + resourcePrefix = path.split("""\W""".toRegex()) + .drop(1) + .distinct() + .joinToString(separator = "_") + .lowercase() + "_" + } + + extensions.configure { + configurePrintApksTask(this) + disableUnnecessaryAndroidTests(target) + } + + dependencies { + add("androidTestImplementation", libs.findLibrary("kotlin.test").get()) + add("testImplementation", libs.findLibrary("kotlin.test").get()) + add("testImplementation", libs.findLibrary("junit").get()) + } + } + } +} diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidLibraryJacocoConventionPlugin.kt b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidLibraryJacocoConventionPlugin.kt new file mode 100644 index 000000000..28a40c007 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidLibraryJacocoConventionPlugin.kt @@ -0,0 +1,25 @@ +/* + * Convention plugin for JaCoCo code coverage on Android library modules + * Configures: JaCoCo plugin, coverage reports (XML + HTML), exclusions + * Applies to: Library modules when code coverage is needed + */ + +import com.android.build.api.dsl.LibraryExtension +import com.android.build.api.variant.LibraryAndroidComponentsExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.getByType +import org.gradle.testing.jacoco.plugins.JacocoPlugin + +class AndroidLibraryJacocoConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + apply() + configureJacoco( + commonExtension = extensions.getByType(), + androidComponentsExtension = extensions.getByType(), + ) + } + } +} diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidLintConventionPlugin.kt b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidLintConventionPlugin.kt new file mode 100644 index 000000000..b87deac31 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidLintConventionPlugin.kt @@ -0,0 +1,40 @@ +/* + * Convention plugin for Android Lint configuration + * Configures: XML/SARIF reports, dependency checking + */ + +import com.android.build.api.dsl.ApplicationExtension +import com.android.build.api.dsl.LibraryExtension +import com.android.build.api.dsl.Lint +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.configure + +class AndroidLintConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + when { + pluginManager.hasPlugin("com.android.application") -> + configure { lint(Lint::configureLint) } + + pluginManager.hasPlugin("com.android.library") -> + configure { lint(Lint::configureLint) } + + else -> { + apply(plugin = "com.android.lint") + configure { configureLint() } + } + } + } + } +} + +private fun Lint.configureLint() { + xmlReport = true + sarifReport = true + checkDependencies = true + + // Disable noisy dependency warnings + disable += "GradleDependency" +} diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidRoomConventionPlugin.kt b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidRoomConventionPlugin.kt new file mode 100644 index 000000000..c9acf47a4 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidRoomConventionPlugin.kt @@ -0,0 +1,37 @@ +/* + * Convention plugin for Room database modules + * Configures: Room 3 plugin, KSP, schema directory, bundled SQLite driver + */ + +import androidx.room3.gradle.RoomExtension +import com.google.devtools.ksp.gradle.KspExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies + +class AndroidRoomConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + apply(plugin = "androidx.room3") + apply(plugin = "com.google.devtools.ksp") + + extensions.configure { + arg("room.generateKotlin", "true") + } + + extensions.configure("room3") { + // Schema directory for Room auto migrations + // See https://developer.android.com/reference/kotlin/androidx/room3/AutoMigration + schemaDirectory("$projectDir/schemas") + } + + dependencies { + add("implementation", libs.findLibrary("room3.runtime").get()) + add("implementation", libs.findLibrary("androidx.sqlite.bundled").get()) + add("ksp", libs.findLibrary("room3.compiler").get()) + } + } + } +} diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidTestConventionPlugin.kt b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidTestConventionPlugin.kt new file mode 100644 index 000000000..0e2c7994a --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/AndroidTestConventionPlugin.kt @@ -0,0 +1,30 @@ +/* + * Convention plugin for Android test modules + * Configures: Test modules for instrumentation testing + * Applies to: test modules (e.g., :benchmark, :baselineprofile) + * Note: AGP 9+ has built-in Kotlin support, no need for kotlin-android plugin + */ + +import com.android.build.api.dsl.TestExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.configure + +class AndroidTestConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + apply(plugin = "com.android.test") + + extensions.configure { + configureKotlinAndroid(this) + + defaultConfig { + targetSdk = libs.findVersion("targetSdk").get().toString().toInt() + } + + configureGradleManagedDevices(this) + } + } + } +} diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/DetektConventionPlugin.kt b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/DetektConventionPlugin.kt new file mode 100644 index 000000000..77753f594 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/DetektConventionPlugin.kt @@ -0,0 +1,64 @@ +/* + * Convention plugin for Detekt static analysis + * Configures: Detekt plugin, Compose rules, baseline, type resolution + * Detekt 2.0+ uses dev.detekt package, Property API, and removed txt report + */ + +import dev.detekt.gradle.Detekt +import dev.detekt.gradle.DetektCreateBaselineTask +import dev.detekt.gradle.extensions.DetektExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.withType + +class DetektConventionPlugin : Plugin { + override fun apply(target: Project) = with(target) { + val detektPluginId = libs.findPlugin("detekt").get().get().pluginId + + pluginManager.apply(detektPluginId) + + dependencies { + add("detektPlugins", libs.findLibrary("compose.rules.detekt").get()) + } + + extensions.configure { + buildUponDefaultConfig.set(true) + basePath.set(rootProject.layout.projectDirectory) + parallel.set(true) + + config.setFrom(rootProject.file("config/detekt.yml")) + + val moduleConfig = project.file("detekt.yml") + if (moduleConfig.exists()) { + config.from(moduleConfig) + } + + baseline.set(project.file("detekt-baseline.xml")) + } + + tasks.withType().configureEach { + jvmTarget.set("17") + + reports { + checkstyle.required.set(true) + html.required.set(true) + sarif.required.set(true) + markdown.required.set(false) + } + + if (project.pluginManager.hasPlugin("org.jetbrains.kotlin.jvm")) { + val javaExtension = extensions.findByType(JavaPluginExtension::class.java) + javaExtension?.let { + classpath.from(it.sourceSets.getByName("main").compileClasspath) + } + } + } + + tasks.withType().configureEach { + jvmTarget.set("17") + } + } +} diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/FirebaseConventionPlugin.kt b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/FirebaseConventionPlugin.kt new file mode 100644 index 000000000..9b2c554f4 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/FirebaseConventionPlugin.kt @@ -0,0 +1,38 @@ +/* + * Convention plugin for Firebase integration + * Configures: Firebase Crashlytics, Analytics + * Applies to: App module when using Firebase + */ + +import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies + +class FirebaseConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + apply(plugin = "com.google.gms.google-services") + apply(plugin = "com.google.firebase.crashlytics") + + dependencies { + val bom = libs.findLibrary("firebase.bom").get() + add("implementation", platform(bom)) + add("implementation", libs.findLibrary("firebase.analytics").get()) + add("implementation", libs.findLibrary("firebase.crashlytics").get()) + } + + extensions.configure { + // Enable collection of native symbols for NDK crashes + nativeSymbolUploadEnabled = true + + // Disable Crashlytics collection in debug builds + if (project.gradle.startParameter.taskNames.any { it.contains("Debug") }) { + mappingFileUploadEnabled = false + } + } + } + } +} diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/HiltConventionPlugin.kt b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/HiltConventionPlugin.kt new file mode 100644 index 000000000..cb37b6661 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/HiltConventionPlugin.kt @@ -0,0 +1,32 @@ +/* + * Convention plugin for Hilt dependency injection + * Configures: Hilt plugin, KSP compiler, common dependencies + */ + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.dependencies + +class HiltConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + apply(plugin = "com.google.devtools.ksp") + apply(plugin = "dagger.hilt.android.plugin") + + val hiltCompiler = libs.findLibrary("hilt.compiler").get() + val hiltTesting = libs.findLibrary("hilt.android.testing").get() + + dependencies { + add("implementation", libs.findLibrary("hilt.android").get()) + add("ksp", hiltCompiler) + + // For testing + add("kspTest", hiltCompiler) + add("testImplementation", hiltTesting) + add("kspAndroidTest", hiltCompiler) + add("androidTestImplementation", hiltTesting) + } + } + } +} diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/JvmLibraryConventionPlugin.kt b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/JvmLibraryConventionPlugin.kt new file mode 100644 index 000000000..4cf65eb76 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/JvmLibraryConventionPlugin.kt @@ -0,0 +1,25 @@ +/* + * Convention plugin for pure JVM/Kotlin library modules + * Configures: Kotlin JVM libraries without Android dependencies + * Applies to: Pure Kotlin modules (e.g., :core:model, utility modules) + */ + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.dependencies + +class JvmLibraryConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + apply(plugin = "org.jetbrains.kotlin.jvm") + apply(plugin = "app.android.lint") + + configureKotlinJvm() + + dependencies { + add("testImplementation", libs.findLibrary("kotlin.test").get()) + } + } + } +} diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/KotlinSerializationConventionPlugin.kt b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/KotlinSerializationConventionPlugin.kt new file mode 100644 index 000000000..480b75b5e --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/KotlinSerializationConventionPlugin.kt @@ -0,0 +1,22 @@ +/* + * Convention plugin for Kotlin Serialization + * Configures: kotlinx-serialization for JSON/data serialization + * Applies to: Modules that need JSON serialization (e.g., network, data) + */ + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.dependencies + +class KotlinSerializationConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + apply(plugin = "org.jetbrains.kotlin.plugin.serialization") + + dependencies { + add("implementation", libs.findLibrary("kotlinx.serialization").get()) + } + } + } +} diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/PlayVitalsReportingConventionPlugin.kt b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/PlayVitalsReportingConventionPlugin.kt new file mode 100644 index 000000000..2b293c6df --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/PlayVitalsReportingConventionPlugin.kt @@ -0,0 +1,21 @@ +/* + * Optional: registers playVitalsReport on the root project only. + * See references/android-performance.md and references/gradle-setup.md + */ + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.register + +class PlayVitalsReportingConventionPlugin : Plugin { + override fun apply(project: Project) { + check(project == project.rootProject) { + "app.play.vitals must be applied only in the root build.gradle.kts" + } + project.tasks.register("playVitalsReport") { + group = "reporting" + description = + "Optional: Play Developer Reporting API vitals (see references/android-performance.md)" + } + } +} diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/PlayVitalsReportingTask.kt b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/PlayVitalsReportingTask.kt new file mode 100644 index 000000000..d52bdcb94 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/PlayVitalsReportingTask.kt @@ -0,0 +1,37 @@ +/* + * Optional Gradle task: entry point for Play Developer Reporting API + Slack. + * Add PlayVitalsRepository, Reporting API deps, and timeline helpers per references/android-performance.md + */ + +import kotlinx.coroutines.runBlocking +import org.gradle.api.DefaultTask +import org.gradle.api.tasks.TaskAction + +abstract class PlayVitalsReportingTask : DefaultTask() { + + @TaskAction + fun report() { + val json = System.getenv("PLAY_REPORTING_SERVICE_ACCOUNT_JSON") + val app = System.getenv("PLAY_REPORTING_APP_RESOURCE") + if (json.isNullOrBlank() || app.isNullOrBlank()) { + logger.warn( + "Skipping play vitals report: set PLAY_REPORTING_SERVICE_ACCOUNT_JSON " + + "and PLAY_REPORTING_APP_RESOURCE", + ) + return + } + runBlocking { + logger.lifecycle( + "Play vitals: env OK for $app. Add PlayVitalsRepository and uncomment the lines below (see references/android-performance.md).", + ) + // Add PlayVitalsRepository to this module and catalog deps, then uncomment: + // val repository = PlayVitalsRepository(appName = app, serviceAccountJson = json) + // val timeline = buildTimelineSpecDaily(...) // GooglePlayDeveloperReportingV1beta1TimelineSpec + // val request = GooglePlayDeveloperReportingV1beta1QueryAnrRateMetricSetRequest() + // .setTimelineSpec(timeline) + // .setMetrics(listOf("anrRate", "anrRate7dUserWeighted", "anrRate28dUserWeighted", ...)) + // val summary = repository.queryAnrRates(request) + // postToSlackAnr(summary) // if summary is null, post "ANR: n/a" or omit section; task still succeeds + } + } +} diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/QUICK_REFERENCE.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/QUICK_REFERENCE.md new file mode 100644 index 000000000..5446bbeaf --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/QUICK_REFERENCE.md @@ -0,0 +1,343 @@ +# Convention Plugins - Setup & Reference + +Required: copy sources from `assets/convention/` into `build-logic/` per [Setup Instructions](#setup-instructions); consumer projects never edit `assets/convention/` in place. + +Forbidden: drift `build-logic` from `assets/convention/` without re-copying — stale plugins ship wrong SDKs, Detekt rules, and Room 3 wiring. + +## Table of Contents + +- [Plugin Mapping](#plugin-mapping-table) +- [Common Plugin Combinations](#common-plugin-combinations) +- [Setup Instructions](#setup-instructions) +- [What Each Plugin Provides](#what-each-plugin-provides) +- [Version Catalog Requirements](#version-catalog-entries-libsversiontoml) +- [Troubleshooting](#troubleshooting) + +## Plugin Mapping Table + +| Plugin ID | File | Purpose | Common Apply To | +|------------------------------------|--------------------------------------------------------|----------------------------|----------------------------------| +| `app.android.application` | `AndroidApplicationConventionPlugin.kt` | Root app module config | `:app` | +| `app.android.application.compose` | `AndroidApplicationComposeConventionPlugin.kt` | Compose compiler only; apply after `app.android.application` | `:app` | +| `app.android.application.baseline` | `AndroidApplicationBaselineProfileConventionPlugin.kt` | Baseline profiles | `:app` | +| `app.android.application.jacoco` | `AndroidApplicationJacocoConventionPlugin.kt` | Code coverage for app | `:app` (when coverage needed) | +| `app.android.library` | `AndroidLibraryConventionPlugin.kt` | Android library | `:core:*`, `:feature:*` | +| `app.android.library.compose` | `AndroidLibraryComposeConventionPlugin.kt` | Compose compiler only; apply after `app.android.library` | UI libraries | +| `app.android.library.jacoco` | `AndroidLibraryJacocoConventionPlugin.kt` | Code coverage for library | Libraries (when coverage needed) | +| `app.android.feature` | `AndroidFeatureConventionPlugin.kt` | Feature module | `:feature:auth`, etc. | +| `app.android.test` | `AndroidTestConventionPlugin.kt` | Test-only module | `:benchmark` | +| `app.android.room` | `AndroidRoomConventionPlugin.kt` | Room 3 database | Modules with DB | +| `app.android.lint` | `AndroidLintConventionPlugin.kt` | Lint analysis | All Android modules | +| `app.hilt` | `HiltConventionPlugin.kt` | Hilt DI | All modules | +| `app.detekt` | `DetektConventionPlugin.kt` | Detekt analysis | All modules | +| `app.spotless` | `SpotlessConventionPlugin.kt` | Code formatting | All modules | +| `app.jvm.library` | `JvmLibraryConventionPlugin.kt` | Pure Kotlin lib | `:core:model` | +| `app.kotlin.serialization` | `KotlinSerializationConventionPlugin.kt` | JSON serialization | Network/data modules | +| `app.firebase` | `FirebaseConventionPlugin.kt` | Firebase Crashlytics | `:app` | +| `app.sentry` | `SentryConventionPlugin.kt` | Sentry crash reporting | `:app` | +| `app.play.vitals` | `PlayVitalsReportingConventionPlugin.kt` | Root-only Play Vitals task | **Root** `build.gradle.kts` only | + +## Common Plugin Combinations + +Required: declare the base Android plugin (`app.android.application` or `app.android.library`) **before** the matching Compose plugin (`app.android.application.compose` or `app.android.library.compose`). Compose convention plugins apply only `org.jetbrains.kotlin.plugin.compose`; they assume `com.android.application` / `com.android.library` is already on the classpath from the base convention. + +### Application Module +```kotlin +plugins { + alias(libs.plugins.app.android.application) + alias(libs.plugins.app.android.application.compose) + alias(libs.plugins.app.hilt) + alias(libs.plugins.app.detekt) + alias(libs.plugins.app.spotless) + alias(libs.plugins.app.firebase) // if using Firebase Crashlytics + alias(libs.plugins.app.sentry) // OR if using Sentry (not both) + alias(libs.plugins.app.android.application.jacoco) // if code coverage needed +} +``` + +### Feature Module +```kotlin +plugins { + alias(libs.plugins.app.android.feature) // includes library + compose + hilt + alias(libs.plugins.app.detekt) + alias(libs.plugins.app.spotless) +} +``` + +### Data Layer (with Room) +```kotlin +plugins { + alias(libs.plugins.app.android.library) + alias(libs.plugins.app.hilt) + alias(libs.plugins.app.android.room) + alias(libs.plugins.app.kotlin.serialization) + alias(libs.plugins.app.detekt) + alias(libs.plugins.app.android.library.jacoco) // if code coverage needed +} +``` + +### UI Library (Compose) +```kotlin +plugins { + alias(libs.plugins.app.android.library) + alias(libs.plugins.app.android.library.compose) + alias(libs.plugins.app.hilt) + alias(libs.plugins.app.detekt) +} +``` + +### Domain/Model (Pure Kotlin) +```kotlin +plugins { + alias(libs.plugins.app.jvm.library) + alias(libs.plugins.app.kotlin.serialization) + alias(libs.plugins.app.detekt) +} +``` + +### Root project (`app.play.vitals`) + +Required: apply `app.play.vitals` only in the **root** `build.gradle.kts`, never in `:app`: +```kotlin +plugins { + // alias(libs.plugins.app.play.vitals) +} +``` +Play Vitals reporting plugin: [android-performance.md](../../references/android-performance.md). + +## Setup Instructions + +### Copy convention plugins + +Required: copy every `.kt` from `assets/convention/` into: +``` +build-logic/convention/src/main/kotlin/ +``` + +### Create `build-logic` tree + +``` +build-logic/ +├── convention/ +│ ├── build.gradle.kts (from `assets/convention/build.gradle.kts`) +│ └── src/main/kotlin/ +│ ├── AndroidApplicationConventionPlugin.kt +│ ├── AndroidLibraryConventionPlugin.kt +│ ├── ... (all other .kt files) +│ └── config/ +│ ├── KotlinAndroid.kt +│ ├── AndroidCompose.kt +│ └── ... (all configuration files) +└── settings.gradle.kts +``` + +### Create `build-logic/settings.gradle.kts` + +```kotlin +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} + +rootProject.name = "build-logic" +include(":convention") +``` + +### Wire `includeBuild("build-logic")` in root `settings.gradle.kts` + +```kotlin +pluginManagement { + includeBuild("build-logic") + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +``` + +### Register plugins in the version catalog + +Required: merge the `[plugins]` block from `assets/libs.versions.toml.template` (search comment `Convention plugins`) into `gradle/libs.versions.toml`. + +### Create Detekt configuration + +Required: `config/detekt.yml` at repo root — start from `assets/detekt.yml.template`. + +### Compose stability configuration + +Use when: enabling Compose compiler stability packages for `core` model classes — add `compose_compiler_config.conf` at repo root: + +``` +// Classes that should be considered stable for Compose +com.example.core.model.* +``` + +## What Each Plugin Provides + +### Android Application Plugin +- Android configuration with built-in Kotlin (compileSdk, minSdk, Java 17) +- Test instrumentation runner +- Gradle managed devices (Pixel 6 API 31, Pixel 8 API 34, Pixel 9 API 36) +- Lint configuration +- Core library desugaring (for API < 26) +- Print APKs task + +### Android Library Plugin +- Same as application + resource prefix based on module path (e.g., `feature_auth_`) +- Disables Android tests for modules without `src/androidTest/` +- Standard testing dependencies (JUnit, kotlin-test) + +### Compose Plugins +- Compose compiler plugin +- Compose BOM dependency (all Compose versions aligned) +- UI tooling (preview + debug) +- Compiler metrics/reports (if enabled via gradle.properties) +- Stability configuration (from `compose_compiler_config.conf`) + +### Feature Plugin +- Android library + Compose + Hilt +- Auto-adds dependencies: `:core:ui`, `:core:domain`, `:core:data` +- Lifecycle (ViewModel + runtime-compose) +- Navigation3 (runtime + compose) +- Adaptive layouts (adaptive, adaptive-layout, adaptive-navigation, navigation-suite) +- Managed devices + +### Room Plugin (Room 3) +- `androidx.room3` Gradle plugin + KSP +- `room3-runtime` + `sqlite-bundled` (for `BundledSQLiteDriver()` on `Room.databaseBuilder`) +- `room3-compiler` (KSP); DAOs use **`suspend`** and **`Flow`** (no separate Room KTX artifact) +- `room3 { schemaDirectory(...) }` for schema export and auto-migrations + +### Hilt Plugin +- Hilt Android + KSP compiler +- Test dependencies (hilt-android-testing) +- KSP for test variants (main, test, androidTest) + +### Detekt Plugin +- Detekt plugin + Compose rules +- Central config (`config/detekt.yml`) +- Module-specific overrides (`detekt.yml` beside the module when needed) +- Baseline support (`detekt-baseline.xml`) +- Type resolution enabled +- XML, HTML, SARIF reports + +### Spotless Plugin +- ktlint for Kotlin formatting +- Format .kts files +- Format XML (for Android modules) +- Trim trailing whitespace +- Ensure newline at end of file + +### Firebase Plugin +- Google Services plugin +- Firebase Crashlytics plugin +- Firebase BOM dependency +- Crashlytics and Analytics libraries +- Crashlytics configuration (native symbols, debug builds) + +### Sentry Plugin +- Sentry Android Gradle plugin +- Sentry Kotlin Compiler plugin (automatic @Composable tagging) +- Sentry Android SDK +- Sentry Compose integration +- Automatic mapping file upload and source context + +Forbidden: apply `app.firebase` and `app.sentry` together unless the product intentionally dual-reports crashes to both backends. + +### JaCoCo Plugins (Code Coverage) +- JaCoCo plugin + version configuration +- Combined coverage reports (unit + instrumented tests) +- Exclusions for generated code (Hilt, R files, BuildConfig) +- XML and HTML reports +- Compatible with Robolectric +- Task: `create{Variant}CombinedCoverageReport` + +JaCoCo workflow (commands, reports): [android-code-coverage.md](../../references/android-code-coverage.md). + +## Configuration Files + +Configuration utilities are located in the `config/` subdirectory: + +| File | Purpose | +|----------------------------------------|------------------------------------------------------------------| +| `config/KotlinAndroid.kt` | Common Kotlin/Android config (SDK, Java 17, desugaring, opt-ins) | +| `config/AndroidCompose.kt` | Compose configuration (BOM, metrics, stability) | +| `config/ProjectExtensions.kt` | Version catalog access (`Project.libs`) | +| `config/GradleManagedDevices.kt` | Emulator configuration for tests (Pixel 6, Pixel 8, Pixel 9) | +| `config/AndroidInstrumentationTest.kt` | Disable unnecessary Android tests | +| `config/PrintApksTask.kt` | Task to print APK paths | + +## Version Catalog Entries (libs.versions.toml) + +Required: align `gradle/libs.versions.toml` with `assets/libs.versions.toml.template` — full copy for greenfield repos, selective merge when preserving existing catalog blocks. + +## gradle.properties Flags + +```properties +# Enable Compose compiler metrics +enableComposeCompilerMetrics=true +# Enable Compose compiler reports +enableComposeCompilerReports=true +``` + +Required output paths after enabling metrics: + +- `build/compose-metrics/` +- `build/compose-reports/` + +## Outcomes + +| Outcome | Mechanism | +|---------------------|--------------------------------------------| +| Consistent SDKs | Single `KotlinAndroid.kt` source | +| Single edit point | Convention plugins + shared `config/` | +| Thin module scripts | `plugins { alias(...) }` only | +| Typed Gradle DSL | Kotlin + version catalog accessors | +| Portable templates | `assets/convention/` + `assets/*.template` | + +## Troubleshooting + +| Issue | Fix | +|---------------------------------|--------------------------------------------------------------------------------------------------| +| Plugin not found | Add `includeBuild("build-logic")` to root `settings.gradle.kts` | +| Version catalog not accessible | Fix `build-logic/settings.gradle.kts` `from(files("../gradle/libs.versions.toml"))` path | +| Type resolution fails in Detekt | `./gradlew --stop`; `./gradlew clean`; apply Android + Kotlin plugins before Detekt | +| Resource prefix errors | Module path must map to prefix (`:feature:auth` → `feature_auth_`) | +| Compose metrics not generated | Set `gradle.properties` flags; apply Compose plugin in the module emitting UI | +| Hilt compiler errors | Apply KSP plugin before Hilt in the same `plugins` block | +| Room schemas not found | Create `$projectDir/schemas/` or disable export until migrations exist | +| Room 3 build fails (driver) | `Room.databaseBuilder` must call `.setDriver(BundledSQLiteDriver())` (or another `SQLiteDriver`) | + +## Migration Checklist + +Room 2→3, Navigation, Compose: [migration.md](../../references/migration.md). + +## Setup Checklist + +- [ ] Copy all `.kt` files to `build-logic/convention/src/main/kotlin/` +- [ ] Add `build-logic/convention/build.gradle.kts` (copy from `assets/convention/build.gradle.kts`) +- [ ] Add `build-logic/settings.gradle.kts` (see step 3 above) +- [ ] Update root `settings.gradle.kts` with `includeBuild("build-logic")` +- [ ] Copy `detekt.yml.template` to `config/detekt.yml` +- [ ] Add convention plugin entries to `gradle/libs.versions.toml` (from template) +- [ ] Ensure Gradle plugin dependencies are in `gradle/libs.versions.toml` (from template) +- [ ] Update module build files to use convention plugins +- [ ] Remove duplicated configuration from modules +- [ ] Test build with `./gradlew build` +- [ ] Verify Detekt with `./gradlew detekt` +- [ ] Verify tests with `./gradlew test` + +## References + +- [Sharing build logic (Gradle docs)](https://docs.gradle.org/current/userguide/sharing_build_logic_between_subprojects.html) +- [Now in Android - Convention plugins](https://github.com/android/nowinandroid/tree/main/build-logic) +- [Version catalogs (Gradle docs)](https://docs.gradle.org/current/userguide/platforms.html#sub:version-catalog) diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/SentryConventionPlugin.kt b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/SentryConventionPlugin.kt new file mode 100644 index 000000000..6bff4811a --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/SentryConventionPlugin.kt @@ -0,0 +1,24 @@ +/* + * Convention plugin for Sentry integration + * Configures: Sentry SDK, Compose integration, Kotlin compiler plugin + * Applies to: App module when using Sentry for crash reporting + */ + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.dependencies + +class SentryConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + apply(plugin = "io.sentry.android.gradle") + apply(plugin = "io.sentry.kotlin.compiler.gradle") + + dependencies { + add("implementation", libs.findLibrary("sentry.android").get()) + add("implementation", libs.findLibrary("sentry.compose.android").get()) + } + } + } +} diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/SpotlessConventionPlugin.kt b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/SpotlessConventionPlugin.kt new file mode 100644 index 000000000..6a984df77 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/SpotlessConventionPlugin.kt @@ -0,0 +1,52 @@ +/* + * Convention plugin for Spotless code formatting + * Configures: ktlint, license headers, formatting + * Applies to: All modules for consistent code style + */ + +import com.diffplug.gradle.spotless.SpotlessExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.configure + +class SpotlessConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + apply(plugin = "com.diffplug.spotless") + + extensions.configure { + kotlin { + target("src/**/*.kt") + ktlint(libs.findVersion("ktlint").get().requiredVersion) + .editorConfigOverride( + mapOf( + "android" to "true", + "max_line_length" to "120" + ) + ) + trimTrailingWhitespace() + endWithNewline() + } + + format("kts") { + target("*.kts", "**/*.kts") + trimTrailingWhitespace() + endWithNewline() + } + + // Format XML files (layouts, resources) + if (pluginManager.hasPlugin("com.android.library") || + pluginManager.hasPlugin("com.android.application") + ) { + format("xml") { + target("src/**/*.xml") + trimTrailingWhitespace() + indentWithSpaces(4) + endWithNewline() + } + } + } + } + } +} diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/build.gradle.kts b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/build.gradle.kts new file mode 100644 index 000000000..22c79c8d5 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/build.gradle.kts @@ -0,0 +1,112 @@ +/* + * Build script for convention plugins + * This module contains reusable convention plugins for the project + */ + +plugins { + `kotlin-dsl` +} + +group = "com.example.buildlogic" + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +dependencies { + compileOnly(libs.android.gradlePlugin) + compileOnly(libs.kotlin.gradlePlugin) + compileOnly(libs.kotlin.composeGradlePlugin) + compileOnly(libs.ksp.gradlePlugin) + compileOnly(libs.room3.gradlePlugin) + implementation(libs.plugin.detekt) + implementation(libs.kotlinx.coroutines.core) +} + +gradlePlugin { + plugins { + register("androidApplication") { + id = "app.android.application" + implementationClass = "AndroidApplicationConventionPlugin" + } + register("androidApplicationCompose") { + id = "app.android.application.compose" + implementationClass = "AndroidApplicationComposeConventionPlugin" + } + register("androidApplicationBaselineProfile") { + id = "app.android.application.baseline" + implementationClass = "AndroidApplicationBaselineProfileConventionPlugin" + } + register("androidApplicationJacoco") { + id = "app.android.application.jacoco" + implementationClass = "AndroidApplicationJacocoConventionPlugin" + } + register("androidLibrary") { + id = "app.android.library" + implementationClass = "AndroidLibraryConventionPlugin" + } + register("androidLibraryCompose") { + id = "app.android.library.compose" + implementationClass = "AndroidLibraryComposeConventionPlugin" + } + register("androidLibraryJacoco") { + id = "app.android.library.jacoco" + implementationClass = "AndroidLibraryJacocoConventionPlugin" + } + register("androidFeature") { + id = "app.android.feature" + implementationClass = "AndroidFeatureConventionPlugin" + } + register("androidTest") { + id = "app.android.test" + implementationClass = "AndroidTestConventionPlugin" + } + register("androidRoom") { + id = "app.android.room" + implementationClass = "AndroidRoomConventionPlugin" + } + register("androidLint") { + id = "app.android.lint" + implementationClass = "AndroidLintConventionPlugin" + } + register("hilt") { + id = "app.hilt" + implementationClass = "HiltConventionPlugin" + } + register("detekt") { + id = "app.detekt" + implementationClass = "DetektConventionPlugin" + } + register("spotless") { + id = "app.spotless" + implementationClass = "SpotlessConventionPlugin" + } + register("jvmLibrary") { + id = "app.jvm.library" + implementationClass = "JvmLibraryConventionPlugin" + } + register("kotlinSerialization") { + id = "app.kotlin.serialization" + implementationClass = "KotlinSerializationConventionPlugin" + } + register("firebase") { + id = "app.firebase" + implementationClass = "FirebaseConventionPlugin" + } + register("sentry") { + id = "app.sentry" + implementationClass = "SentryConventionPlugin" + } + register("playVitals") { + id = "app.play.vitals" + implementationClass = "PlayVitalsReportingConventionPlugin" + } + } +} diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/config/AndroidCompose.kt b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/config/AndroidCompose.kt new file mode 100644 index 000000000..77a0e25a8 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/config/AndroidCompose.kt @@ -0,0 +1,60 @@ +/* + * Compose configuration utilities + * Configures: Compose features, compiler metrics, stability configuration + */ + +import com.android.build.api.dsl.CommonExtension +import org.gradle.api.Project +import org.gradle.api.provider.Provider +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies +import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension + +/** + * Configure Compose-specific options + */ +internal fun Project.configureAndroidCompose( + commonExtension: CommonExtension, +) { + commonExtension.apply { + buildFeatures.compose = true + + dependencies { + val bom = libs.findLibrary("androidx.compose.bom").get() + add("implementation", platform(bom)) + add("androidTestImplementation", platform(bom)) + add("implementation", libs.findLibrary("androidx.compose.ui.tooling.preview").get()) + add("debugImplementation", libs.findLibrary("androidx.compose.ui.tooling").get()) + } + } + + extensions.configure { + fun Provider.onlyIfTrue() = + flatMap { provider { it.takeIf(String::toBoolean) } } + + fun Provider<*>.relativeToRootProject(dir: String) = map { + @Suppress("UnstableApiUsage") + isolated.rootProject.projectDirectory + .dir("build") + .dir(projectDir.toRelativeString(rootDir)) + }.map { it.dir(dir) } + + // Enable Compose compiler metrics (set enableComposeCompilerMetrics=true in gradle.properties) + project.providers.gradleProperty("enableComposeCompilerMetrics") + .onlyIfTrue() + .relativeToRootProject("compose-metrics") + .let(metricsDestination::set) + + // Enable Compose compiler reports (set enableComposeCompilerReports=true in gradle.properties) + project.providers.gradleProperty("enableComposeCompilerReports") + .onlyIfTrue() + .relativeToRootProject("compose-reports") + .let(reportsDestination::set) + + // Compose stability configuration file + @Suppress("UnstableApiUsage") + stabilityConfigurationFiles.add( + isolated.rootProject.projectDirectory.file("compose_compiler_config.conf") + ) + } +} diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/config/AndroidInstrumentationTest.kt b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/config/AndroidInstrumentationTest.kt new file mode 100644 index 000000000..4178a7025 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/config/AndroidInstrumentationTest.kt @@ -0,0 +1,18 @@ +/* + * Android instrumentation test utilities + * Configures: Disable unnecessary Android tests for non-UI modules + */ + +import com.android.build.api.variant.LibraryAndroidComponentsExtension +import org.gradle.api.Project + +/** + * Disable unnecessary Android instrumentation tests for modules without UI + * This improves build performance by skipping test APK generation + */ +internal fun LibraryAndroidComponentsExtension.disableUnnecessaryAndroidTests( + project: Project, +) = beforeVariants { + it.enableAndroidTest = it.enableAndroidTest && + project.projectDir.resolve("src/androidTest").exists() +} diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/config/GradleManagedDevices.kt b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/config/GradleManagedDevices.kt new file mode 100644 index 000000000..1ec44edea --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/config/GradleManagedDevices.kt @@ -0,0 +1,57 @@ +/* + * Gradle Managed Devices configuration + * Configures: Emulator devices for instrumentation tests + * Note: AGP 9+ uses localDevices/create instead of devices/maybeCreate + */ + +import com.android.build.api.dsl.CommonExtension +import org.gradle.kotlin.dsl.get +import org.gradle.kotlin.dsl.invoke + +/** + * Configure project for Gradle managed devices + */ +internal fun configureGradleManagedDevices( + commonExtension: CommonExtension, +) { + val pixel6Api31 = DeviceConfig("Pixel 6", 31, "aosp") + val pixel8Api34 = DeviceConfig("Pixel 8", 34, "google") + val pixel9Api36 = DeviceConfig("Pixel 9", 36, "google") + + val allDevices = listOf(pixel6Api31, pixel8Api34, pixel9Api36) + val ciDevices = listOf(pixel6Api31) + + commonExtension.testOptions.apply { + managedDevices { + localDevices { + allDevices.forEach { deviceConfig -> + create(deviceConfig.taskName) { + device = deviceConfig.device + apiLevel = deviceConfig.apiLevel + systemImageSource = deviceConfig.systemImageSource + } + } + } + groups { + create("ci") { + ciDevices.forEach { deviceConfig -> + targetDevices.add(localDevices[deviceConfig.taskName]) + } + } + } + } + } +} + +private data class DeviceConfig( + val device: String, + val apiLevel: Int, + val systemImageSource: String, +) { + val taskName = buildString { + append(device.lowercase().replace(" ", "")) + append("api") + append(apiLevel.toString()) + append(systemImageSource.replace("-", "")) + } +} diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/config/Jacoco.kt b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/config/Jacoco.kt new file mode 100644 index 000000000..3edc3f869 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/config/Jacoco.kt @@ -0,0 +1,140 @@ +/* + * JaCoCo configuration for Android modules + * Generates combined coverage reports from unit and instrumented tests + */ + +import com.android.build.api.artifact.ScopedArtifact +import com.android.build.api.dsl.CommonExtension +import com.android.build.api.variant.AndroidComponentsExtension +import com.android.build.api.variant.ScopedArtifacts +import com.android.build.api.variant.SourceDirectories +import org.gradle.api.Project +import org.gradle.api.file.Directory +import org.gradle.api.file.RegularFile +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.testing.Test +import org.gradle.kotlin.dsl.assign +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.register +import org.gradle.kotlin.dsl.withType +import org.gradle.testing.jacoco.plugins.JacocoPluginExtension +import org.gradle.testing.jacoco.tasks.JacocoReport +import org.gradle.testing.jacoco.tasks.JacocoReportsContainer +import java.util.Locale + +private val coverageExclusions = listOf( + // Android + "**/R.class", + "**/R\$*.class", + "**/BuildConfig.*", + "**/Manifest*.*", + "**/Hilt_*.class", + "**/*_Hilt*.class", + "**/*_Factory.class", + "**/*_MembersInjector.class", + "**/Dagger*.class", + "**/*Module.class", + "**/*Component.class", + "**/*ComponentImpl.class", +) + +private fun String.capitalize() = replaceFirstChar { + if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() +} + +/** + * Creates a new task that generates a combined coverage report with data from local and + * instrumented tests. + * + * Task name: `create{variant}CombinedCoverageReport` + * + * Example: `./gradlew createDebugCombinedCoverageReport` + * + * Coverage data must exist before running the task. Run tests first: + * - Unit tests: `./gradlew testDebugUnitTest` + * - Instrumented tests: `./gradlew connectedDebugAndroidTest` + * + * If configuration fails with MissingValueException / unresolved providers on `compile*JavaWithJavac` after an AGP bump, isolate `ScopedArtifacts` wiring here before chasing Kotlin pins; see `references/android-code-coverage.md`. + */ +internal fun Project.configureJacoco( + commonExtension: CommonExtension, + androidComponentsExtension: AndroidComponentsExtension<*, *, *>, +) { + // Configure only the debug build + commonExtension.buildTypes.named("debug") { + enableAndroidTestCoverage = true + enableUnitTestCoverage = true + } + + configure { + toolVersion = libs.findVersion("jacoco").get().toString() + } + + androidComponentsExtension.onVariants { variant -> + val myObjFactory = project.objects + val buildDir = layout.buildDirectory.get().asFile + val allJars: ListProperty = myObjFactory.listProperty(RegularFile::class.java) + val allDirectories: ListProperty = + myObjFactory.listProperty(Directory::class.java) + + val reportTask = + tasks.register( + "create${variant.name.capitalize()}CombinedCoverageReport", + ) { + classDirectories.setFrom( + allJars, + allDirectories.map { dirs -> + dirs.map { dir -> + myObjFactory.fileTree().setDir(dir).exclude(coverageExclusions) + } + }, + ) + + reports { + xml.required = true + html.required = true + } + + fun SourceDirectories.Flat?.toFilePaths(): Provider> = this + ?.all + ?.map { directories -> directories.map { it.asFile.path } } + ?: provider { emptyList() } + + sourceDirectories.setFrom( + files( + variant.sources.java.toFilePaths(), + variant.sources.kotlin.toFilePaths(), + ), + ) + + executionData.setFrom( + project.fileTree("$buildDir/outputs/unit_test_code_coverage/${variant.name}UnitTest") + .matching { include("**/*.exec") }, + + project.fileTree("$buildDir/outputs/code_coverage/${variant.name}AndroidTest") + .matching { include("**/*.ec") }, + ) + } + + variant.artifacts.forScope(ScopedArtifacts.Scope.PROJECT) + .use(reportTask) + .toGet( + ScopedArtifact.CLASSES, + { _ -> allJars }, + { _ -> allDirectories }, + ) + } + + tasks.withType().configureEach { + configure { + // Required for JaCoCo + Robolectric + // https://github.com/robolectric/robolectric/issues/2230 + isIncludeNoLocationClasses = true + + // Required for JDK 11+ + // https://github.com/gradle/gradle/issues/5184#issuecomment-391982009 + excludes = listOf("jdk.internal.*") + } + } +} diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/config/KotlinAndroid.kt b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/config/KotlinAndroid.kt new file mode 100644 index 000000000..de33b1bb0 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/config/KotlinAndroid.kt @@ -0,0 +1,106 @@ +/* + * Kotlin and Android configuration utilities + * Configures: compileSdk, minSdk, Java version, Kotlin compiler options + * AGP 9+ uses built-in Kotlin; compiler options are set via KotlinCompile tasks. + */ + +import com.android.build.api.dsl.CommonExtension +import org.gradle.api.JavaVersion +import org.gradle.api.Project +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.kotlin.dsl.assign +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +/** + * Configure base Kotlin with Android options + */ +internal fun Project.configureKotlinAndroid( + commonExtension: CommonExtension, +) { + commonExtension.apply { + compileSdk { + version = release(libs.findVersion("compileSdk").get().toString().toInt()) + } + + defaultConfig.apply { + minSdk = libs.findVersion("minSdk").get().toString().toInt() + } + + compileOptions.apply { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true // Required for API < 26 (java.time, Duration API) + } + } + + configureKotlinCompileTasks() + + dependencies { + add("coreLibraryDesugaring", libs.findLibrary("androidx.core.desugaring").get()) + } +} + +/** + * Configure base Kotlin options for JVM (non-Android) + */ +internal fun Project.configureKotlinJvm() { + extensions.configure { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + configureKotlin() +} + +/** + * Configure Kotlin compiler options via KotlinCompile tasks. + * Works with AGP 9+ built-in Kotlin where KotlinAndroidProjectExtension is not registered. + */ +private fun Project.configureKotlinCompileTasks() { + val warningsAsErrors = providers.gradleProperty("warningsAsErrors") + .map { it.toBoolean() } + .orElse(false) + + tasks.withType().configureEach { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + allWarningsAsErrors = warningsAsErrors + freeCompilerArgs.addAll( + "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", + "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api", + "-opt-in=androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi", + "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi", + ) + } + } +} + +/** + * Configure Kotlin options for JVM projects via extension + */ +private inline fun Project.configureKotlin() = + configure { + val warningsAsErrors = providers.gradleProperty("warningsAsErrors") + .map { it.toBoolean() } + .orElse(false) + + when (this) { + is KotlinJvmProjectExtension -> compilerOptions + else -> TODO("Unsupported project extension $this ${T::class}") + }.apply { + jvmTarget = JvmTarget.JVM_17 + allWarningsAsErrors = warningsAsErrors + + freeCompilerArgs.addAll( + "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", + "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api", + "-opt-in=androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi", + "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi", + ) + } + } diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/config/PrintApksTask.kt b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/config/PrintApksTask.kt new file mode 100644 index 000000000..15afb8f98 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/config/PrintApksTask.kt @@ -0,0 +1,31 @@ +/* + * Print APKs task configuration + * Creates task to print all generated APK paths + */ + +import com.android.build.api.variant.AndroidComponentsExtension +import org.gradle.api.Project +import org.gradle.kotlin.dsl.register + +/** + * Configure task to print all APK paths for a project + * Usage: ./gradlew printApks + */ +internal fun Project.configurePrintApksTask( + extension: AndroidComponentsExtension<*, *, *>, +) { + extension.onVariants { variant -> + tasks.register("print${variant.name.capitalize()}Apks") { + group = "help" + description = "Prints all APK paths for ${variant.name} variant" + + doLast { + println("APKs for ${variant.name}:") + variant.artifacts.getAll(com.android.build.api.artifact.SingleArtifact.APK) + .forEach { apk -> + println(" - ${apk.absolutePath}") + } + } + } + } +} diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/config/ProjectExtensions.kt b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/config/ProjectExtensions.kt new file mode 100644 index 000000000..b10de90f2 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/convention/config/ProjectExtensions.kt @@ -0,0 +1,15 @@ +/* + * Project extension utilities + * Provides: Version catalog accessor + */ + +import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalog +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.kotlin.dsl.getByType + +/** + * Access the libs version catalog from any Project + */ +val Project.libs: VersionCatalog + get() = extensions.getByType().named("libs") diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/detekt.yml.template b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/detekt.yml.template new file mode 100644 index 000000000..10269754f --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/detekt.yml.template @@ -0,0 +1,716 @@ +config: + validation: true + excludes: [] + +processors: + active: true + exclude: + - 'DetektProgressListener' + +console-reports: + active: true + exclude: + - 'ProjectStatisticsReport' + - 'ComplexityReport' + - 'NotificationReport' + - 'FileBasedFindingsReport' + +comments: + active: true + excludes: + - '**/test/**' + - '**/androidTest/**' + - '**/*.Test.kt' + - '**/*.Spec.kt' + - '**/*.Spek.kt' + DocumentationOverPrivateFunction: + active: false + DocumentationOverPrivateProperty: + active: false + EndOfSentenceFormat: + active: false + endOfSentenceFormat: ([.?!][ \t\n\r\f<])|([.?!:]$) + UndocumentedPublicClass: + active: false + searchInNestedClass: true + searchInInnerClass: true + searchInInnerObject: true + searchInInnerInterface: true + UndocumentedPublicFunction: + active: false + UndocumentedPublicProperty: + active: false + +complexity: + active: true + ComplexCondition: + active: true + threshold: 4 + ComplexInterface: + active: false + threshold: 10 + includeStaticDeclarations: false + CyclomaticComplexMethod: + active: true + threshold: 15 + ignoreSingleWhenExpression: false + ignoreSimpleWhenEntries: false + ignoreNestingFunctions: false + nestingFunctions: + - 'run' + - 'let' + - 'apply' + - 'with' + - 'also' + - 'use' + - 'forEach' + - 'isNotNull' + - 'ifNull' + LabeledExpression: + active: false + ignoredLabels: [] + LargeClass: + active: true + threshold: 600 + LongMethod: + active: true + threshold: 60 + LongParameterList: + active: true + functionThreshold: 8 + constructorThreshold: 6 + ignoreDefaultParameters: true + MethodOverloading: + active: false + threshold: 6 + NestedBlockDepth: + active: true + threshold: 4 + StringLiteralDuplication: + active: false + excludes: + - '**/test/**' + - '**/androidTest/**' + - '**/*.Test.kt' + - '**/*.Spec.kt' + - '**/*.Spek.kt' + threshold: 3 + ignoreAnnotation: true + excludeStringsWithLessThan5Characters: true + ignoreStringsRegex: '$^' + TooManyFunctions: + active: false + excludes: + - '**/test/**' + - '**/androidTest/**' + - '**/*.Test.kt' + - '**/*.Spec.kt' + - '**/*.Spek.kt' + thresholdInFiles: 11 + thresholdInClasses: 11 + thresholdInInterfaces: 11 + thresholdInObjects: 11 + thresholdInEnums: 11 + ignoreDeprecated: false + ignorePrivate: false + ignoreOverridden: false + +coroutines: + active: true + GlobalCoroutineUsage: + active: false + RedundantSuspendModifier: + active: true + +empty-blocks: + active: true + EmptyCatchBlock: + active: true + allowedExceptionNameRegex: "^(_|(ignore|expected).*)" + EmptyClassBlock: + active: true + EmptyDefaultConstructor: + active: true + EmptyDoWhileBlock: + active: true + EmptyElseBlock: + active: true + EmptyFinallyBlock: + active: true + EmptyForBlock: + active: true + EmptyFunctionBlock: + active: true + ignoreOverridden: false + EmptyIfBlock: + active: true + EmptyInitBlock: + active: true + EmptyKtFile: + active: true + EmptySecondaryConstructor: + active: true + EmptyWhenBlock: + active: true + EmptyWhileBlock: + active: true + +exceptions: + active: true + ExceptionRaisedInUnexpectedLocation: + active: false + methodNames: + - 'toString' + - 'hashCode' + - 'equals' + - 'finalize' + InstanceOfCheckForException: + active: false + excludes: + - '**/test/**' + - '**/androidTest/**' + - '**/*.Test.kt' + - '**/*.Spec.kt' + - '**/*.Spek.kt' + NotImplementedDeclaration: + active: false + PrintStackTrace: + active: false + RethrowCaughtException: + active: false + ReturnFromFinally: + active: false + ignoreLabeled: false + SwallowedException: + active: false + ignoredExceptionTypes: + - 'InterruptedException' + - 'NumberFormatException' + - 'ParseException' + - 'MalformedURLException' + allowedExceptionNameRegex: "^(_|(ignore|expected).*)" + ThrowingExceptionFromFinally: + active: false + ThrowingExceptionInMain: + active: false + ThrowingExceptionsWithoutMessageOrCause: + active: false + exceptions: + - 'IllegalArgumentException' + - 'IllegalStateException' + - 'IOException' + ThrowingNewInstanceOfSameException: + active: false + TooGenericExceptionCaught: + active: true + excludes: + - '**/test/**' + - '**/androidTest/**' + - '**/*.Test.kt' + - '**/*.Spec.kt' + - '**/*.Spek.kt' + exceptionNames: + - ArrayIndexOutOfBoundsException + - Error + - Exception + - IllegalMonitorStateException + - NullPointerException + - IndexOutOfBoundsException + - RuntimeException + - Throwable + allowedExceptionNameRegex: "^(_|(ignore|expected).*)" + TooGenericExceptionThrown: + active: true + exceptionNames: + - Error + - Exception + - Throwable + - RuntimeException + +naming: + active: true + ClassNaming: + active: true + excludes: + - '**/test/**' + - '**/androidTest/**' + - '**/*.Test.kt' + - '**/*.Spec.kt' + - '**/*.Spek.kt' + classPattern: '[A-Z$][a-zA-Z0-9$]*' + ConstructorParameterNaming: + active: true + excludes: + - '**/test/**' + - '**/androidTest/**' + - '**/*.Test.kt' + - '**/*.Spec.kt' + - '**/*.Spek.kt' + parameterPattern: '[a-z][A-Za-z0-9]*' + privateParameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + EnumNaming: + active: true + excludes: + - '**/test/**' + - '**/androidTest/**' + - '**/*.Test.kt' + - '**/*.Spec.kt' + - '**/*.Spek.kt' + enumEntryPattern: '^[A-Z][_a-zA-Z0-9]*' + ForbiddenClassName: + active: false + excludes: + - '**/test/**' + - '**/androidTest/**' + - '**/*.Test.kt' + - '**/*.Spec.kt' + - '**/*.Spek.kt' + forbiddenName: [] + FunctionMaxLength: + active: false + excludes: + - '**/test/**' + - '**/androidTest/**' + - '**/*.Test.kt' + - '**/*.Spec.kt' + - '**/*.Spek.kt' + maximumFunctionNameLength: 30 + FunctionMinLength: + active: false + excludes: + - '**/test/**' + - '**/androidTest/**' + - '**/*.Test.kt' + - '**/*.Spec.kt' + - '**/*.Spek.kt' + minimumFunctionNameLength: 3 + FunctionNaming: + active: true + excludes: + - '**/test/**' + - '**/androidTest/**' + - '**/*.Test.kt' + - '**/*.Spec.kt' + - '**/*.Spek.kt' + functionPattern: '^([a-zA-Z$][a-zA-Z$0-9]*)|(`.*`)$' + excludeClassPattern: '$^' + ignoreAnnotated: + - 'Composable' + FunctionParameterNaming: + active: true + excludes: + - '**/test/**' + - '**/androidTest/**' + - '**/*.Test.kt' + - '**/*.Spec.kt' + - '**/*.Spek.kt' + parameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + InvalidPackageDeclaration: + active: false + rootPackage: '' + MatchingDeclarationName: + active: true + MemberNameEqualsClassName: + active: true + ignoreOverridden: true + ObjectPropertyNaming: + active: true + excludes: + - '**/test/**' + - '**/androidTest/**' + - '**/*.Test.kt' + - '**/*.Spec.kt' + - '**/*.Spek.kt' + constantPattern: '[A-Za-z][_A-Za-z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' + PackageNaming: + active: true + excludes: + - '**/test/**' + - '**/androidTest/**' + - '**/*.Test.kt' + - '**/*.Spec.kt' + - '**/*.Spek.kt' + packagePattern: '^[a-z]+(\.[a-z][A-Za-z0-9]*)*$' + TopLevelPropertyNaming: + active: true + excludes: + - '**/test/**' + - '**/androidTest/**' + - '**/*.Test.kt' + - '**/*.Spec.kt' + - '**/*.Spek.kt' + constantPattern: '[A-Z][_A-Z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' + VariableMaxLength: + active: false + excludes: + - '**/test/**' + - '**/androidTest/**' + - '**/*.Test.kt' + - '**/*.Spec.kt' + - '**/*.Spek.kt' + maximumVariableNameLength: 64 + VariableMinLength: + active: false + excludes: + - '**/test/**' + - '**/androidTest/**' + - '**/*.Test.kt' + - '**/*.Spec.kt' + - '**/*.Spek.kt' + minimumVariableNameLength: 1 + VariableNaming: + active: true + excludes: + - '**/test/**' + - '**/androidTest/**' + - '**/*.Test.kt' + - '**/*.Spec.kt' + - '**/*.Spek.kt' + variablePattern: '[a-z][A-Za-z0-9]*' + privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + +performance: + active: true + ArrayPrimitive: + active: true + ForEachOnRange: + active: true + excludes: + - '**/test/**' + - '**/androidTest/**' + - '**/*.Test.kt' + - '**/*.Spec.kt' + - '**/*.Spek.kt' + SpreadOperator: + active: false + excludes: + - '**/test/**' + - '**/androidTest/**' + - '**/*.Test.kt' + - '**/*.Spec.kt' + - '**/*.Spek.kt' + UnnecessaryTemporaryInstantiation: + active: true + +potential-bugs: + active: true + Deprecation: + active: true + EqualsAlwaysReturnsTrueOrFalse: + active: true + EqualsWithHashCodeExist: + active: true + ExplicitGarbageCollectionCall: + active: true + HasPlatformType: + active: false + ImplicitDefaultLocale: + active: false + InvalidRange: + active: true + IteratorHasNextCallsNextMethod: + active: true + IteratorNotThrowingNoSuchElementException: + active: true + LateinitUsage: + active: false + excludes: + - '**/test/**' + - '**/androidTest/**' + - '**/*.Test.kt' + - '**/*.Spec.kt' + - '**/*.Spek.kt' + ignoreAnnotated: [] + ignoreOnClassesPattern: "" + MapGetWithNotNullAssertionOperator: + active: false + UnconditionalJumpStatementInLoop: + active: false + UnreachableCode: + active: true + UnsafeCallOnNullableType: + active: true + UnsafeCast: + active: false + UselessPostfixExpression: + active: false + WrongEqualsTypeParameter: + active: true + +style: + active: true + CollapsibleIfStatements: + active: false + DataClassContainsFunctions: + active: false + conversionFunctionPrefix: + - 'to' + DataClassShouldBeImmutable: + active: false + EqualsNullCall: + active: true + EqualsOnSignatureLine: + active: false + ExplicitCollectionElementAccessMethod: + active: false + ExplicitItLambdaParameter: + active: false + ExpressionBodySyntax: + active: false + includeLineWrapping: false + ForbiddenComment: + active: true + comments: + - 'TODO:' + - 'FIXME:' + - 'STOPSHIP:' + allowedPatterns: "" + ForbiddenImport: + active: true + imports: + - 'androidx.lifecycle.LiveData' + - 'androidx.lifecycle.MutableLiveData' + forbiddenPatterns: "" + ForbiddenMethodCall: + active: false + methods: [] + ForbiddenVoid: + active: false + ignoreOverridden: false + ignoreUsageInGenerics: false + FunctionOnlyReturningConstant: + active: true + ignoreOverridableFunction: true + excludedFunctions: + - 'describeContents' + ignoreAnnotated: + - "dagger.Provides" + LoopWithTooManyJumpStatements: + active: true + maxJumpCount: 1 + MagicNumber: + active: true + excludes: + - '**/test/**' + - '**/androidTest/**' + - '**/*.Test.kt' + - '**/*.Spec.kt' + - '**/*.Spek.kt' + ignoreNumbers: + - '-1' + - '0' + - '1' + - '2' + ignoreHashCodeFunction: true + ignorePropertyDeclaration: true + ignoreLocalVariableDeclaration: false + ignoreConstantDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + ignoreAnnotation: false + ignoreNamedArgument: true + ignoreEnums: false + ignoreRanges: false + BracesOnIfStatements: + active: false + MaxLineLength: + active: true + maxLineLength: 120 + excludePackageStatements: true + excludeImportStatements: true + excludeCommentStatements: false + MayBeConst: + active: true + ModifierOrder: + active: true + NestedClassesVisibility: + active: false + NewLineAtEndOfFile: + active: true + excludes: + - '**/*.kt' + NoTabs: + active: false + OptionalAbstractKeyword: + active: true + OptionalUnit: + active: false + PreferToOverPairSyntax: + active: false + ProtectedMemberInFinalClass: + active: true + RedundantExplicitType: + active: false + RedundantVisibilityModifierRule: + active: false + ReturnCount: + active: true + max: 2 + excludedFunctions: + - "equals" + excludeLabeled: false + excludeReturnFromLambda: true + excludeGuardClauses: false + SafeCast: + active: true + SerialVersionUIDInSerializableClass: + active: false + SpacingBetweenPackageAndImports: + active: false + ThrowsCount: + active: true + max: 2 + TrailingWhitespace: + active: false + UnderscoresInNumericLiterals: + active: false + acceptableLength: 5 + UnnecessaryAbstractClass: + active: true + ignoreAnnotated: + - "dagger.Module" + UnnecessaryApply: + active: false + UnnecessaryInheritance: + active: true + UnnecessaryLet: + active: false + UnnecessaryParentheses: + active: false + UntilInsteadOfRangeTo: + active: false + UnusedImports: + active: false + UnusedPrivateClass: + active: true + UnusedPrivateMember: + active: false + allowedNames: "(_|ignored|expected|serialVersionUID)" + ignoreAnnotated: + - 'Preview' + UseArrayLiteralsInAnnotations: + active: false + UseCheckOrError: + active: false + UseDataClass: + active: false + ignoreAnnotated: [] + allowVars: false + UseIfInsteadOfWhen: + active: false + UseRequire: + active: false + UselessCallOnNotNull: + active: true + UtilityClassWithPublicConstructor: + active: true + VarCouldBeVal: + active: false + WildcardImport: + active: true + excludes: + - '**/test/**' + - '**/androidTest/**' + - '**/*.Test.kt' + - '**/*.Spec.kt' + - '**/*.Spek.kt' + excludeImports: + - 'java.util.*' + +# Disable ktlint rules that are overly opinionated for most projects. +# These are registered automatically by Detekt 2.0's ktlint wrapper. +ktlint: + TrailingCommaOnCallSite: + active: false + TrailingCommaOnDeclarationSite: + active: false + ChainMethodContinuation: + active: false + ClassSignature: + active: false + FunctionSignature: + active: false + NoEmptyFirstLineInClassBody: + active: false + BlankLineBeforeDeclaration: + active: false + EnumWrapping: + active: false + FunctionExpressionBody: + active: false + BackingPropertyNaming: + active: false + MultilineExpressionWrapping: + active: false + +Compose: + ComposableAnnotationNaming: + active: true + ComposableNaming: + active: true + ComposableParamOrder: + active: true + CompositionLocalAllowlist: + active: true + CompositionLocalNaming: + active: true + ContentEmitterReturningValues: + active: true + ContentTrailingLambda: + active: true + ContentSlotReused: + active: true + DefaultsVisibility: + active: true + LambdaParameterEventTrailing: + active: true + LambdaParameterInRestartableEffect: + active: true + Material2: + active: true + ModifierClickableOrder: + active: true + ModifierComposed: + active: false # Migrating Modifier.composed to Modifier.Node requires significant refactoring + ModifierMissing: + active: true + ModifierNaming: + active: true + ModifierNotUsedAtRoot: + active: true + ModifierReused: + active: true + ModifierWithoutDefault: + active: true + MultipleEmitters: + active: true + MutableParams: + active: true + MutableStateAutoboxing: + active: true + MutableStateParam: + active: true + ParameterNaming: + active: true + PreviewAnnotationNaming: + active: true + PreviewNaming: + active: false + PreviewPublic: + active: true + RememberMissing: + active: true + RememberContentMissing: + active: true + UnstableCollections: + active: false + ViewModelForwarding: + active: true + ViewModelInjection: + active: true diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/libs.versions.toml.template b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/libs.versions.toml.template new file mode 100644 index 000000000..6d5ae9a53 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/libs.versions.toml.template @@ -0,0 +1,249 @@ +// libs.versions.toml +[versions] +activityCompose = "1.11.0" +agp = "9.2.0" +androidxTestRunner = "1.6.2" +androidxBiometric = "1.4.0-alpha07" +androidxSecurityCrypto = "1.1.0" +baselineprofile = "1.3.5" +benchmark = "1.3.0" +coil = "3.0.4" +compileSdk = "37" +compose-bom = "2026.04.01" +composeAnimation = "1.11.0" +composeRules = "0.5.0" +composeStabilityAnalyzer = "0.6.6" +constraintlayoutCompose = "1.1.1" +coreKtx = "1.17.0" +coroutines = "1.10.2" +desugarJdk = "2.1.5" +detekt = "2.0.0-alpha.3" +espressoCore = "3.7.0" +firebase-bom = "34.0.0" +firebaseCrashlyticsPlugin = "3.0.6" +googleAuthLibraryOauth2Http = "1.30.1" +googlePlayDeveloperReporting = "v1beta1-rev20260305-2.0.0" +googleServices = "4.4.2" +hilt = "2.59.2" +jacoco = "0.8.12" +javaxInjectVersion = "1" +junit = "4.13.2" +junitVersion = "1.3.0" +kotlin = "2.3.21" +kotlinLogging = "7.0.3" +kotlinxDatetime = "0.7.1" +kotlinxImmutable = "0.3.8" +kotlinxSerialization = "1.9.0" +ksp = "2.3.7" +ktlint = "13.1.0" +ktor = "3.0.3" +leakcanary = "2.14" +lifecycleRuntimeCompose = "2.10.0" +lifecycleRuntimeKtx = "2.10.0" +lifecycleViewmodel = "2.10.0" +lifecycleViewmodelCompose = "2.10.0" +material3 = "1.4.0" +materialAdaptive = "1.3.0-alpha10" +minSdk = "24" +mockkVersion = "1.14.6" +mockwebserver = "5.3.2" +navigation3 = "1.1.1" +okhttpLogging = "5.3.2" +paging = "3.3.6" +palette = "1.0.0" +playIntegrity = "1.4.0" +profileinstaller = "1.4.1" +retrofit2 = "3.0.0" +robolectric = "4.16.1" +room3 = "3.0.0-alpha03" +screenshot = "0.0.1-alpha14" +savedstateCompose = "1.2.1" +splashscreen = "1.0.1" +startup = "1.2.0" +sentry = "8.30.0" +sentryPlugin = "5.12.2" +slf4j = "2.0.3" +spotless = "6.25.0" +sqlite = "2.6.2" +sqlcipher = "4.6.1" +targetSdk = "37" +tracing = "2.0.0-alpha06" +truth = "1.4.5" +turbine = "1.2.1" +uiautomator = "2.3.0" +work = "2.10.0" + +[libraries] +android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "benchmark" } +androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "androidxBiometric" } +androidx-compose-animation = { group = "androidx.compose.animation", name = "animation", version.ref = "composeAnimation" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } +androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } +androidx-compose-material3-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive", version.ref = "materialAdaptive" } +androidx-compose-material3-adaptive-layout = { group = "androidx.compose.material3.adaptive", name = "adaptive-layout", version.ref = "materialAdaptive" } +androidx-compose-material3-adaptive-navigation = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation", version.ref = "materialAdaptive" } +androidx-compose-material3-adaptive-navigation-suite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite", version.ref = "material3" } +androidx-compose-material3-adaptive-navigation3 = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3", version.ref = "materialAdaptive" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-constraintlayout-compose = { group = "androidx.constraintlayout", name = "constraintlayout-compose", version.ref = "constraintlayoutCompose" } +androidx-core-desugaring = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "desugarJdk" } +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } +androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodel" } +androidx-navigation3-compose = { group = "androidx.navigation3", name = "navigation3-compose", version.ref = "navigation3" } +androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" } +androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" } +androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging" } +androidx-paging-runtime = { group = "androidx.paging", name = "paging-runtime", version.ref = "paging" } +androidx-paging-testing = { group = "androidx.paging", name = "paging-testing", version.ref = "paging" } +androidx-palette = { group = "androidx.palette", name = "palette", version.ref = "palette" } +androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" } +androidx-savedstate-compose-serialization = { group = "androidx.savedstate", name = "savedstate-compose", version.ref = "savedstateCompose" } +androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "androidxSecurityCrypto" } +androidx-sqlite-bundled = { group = "androidx.sqlite", name = "sqlite-bundled", version.ref = "sqlite" } +androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" } +androidx-startup-runtime = { group = "androidx.startup", name = "startup-runtime", version.ref = "startup" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" } +androidx-test-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" } +androidx-tracing = { group = "androidx.tracing", name = "tracing-android", version.ref = "tracing" } +androidx-tracing-wire = { group = "androidx.tracing", name = "tracing-wire-android", version.ref = "tracing" } +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } +coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } +coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" } +compose-rules-detekt = { module = "io.nlopez.compose.rules:detekt", version.ref = "composeRules" } +firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" } +firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebase-bom" } +firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" } +google-api-services-playdeveloperreporting = { module = "com.google.apis:google-api-services-playdeveloperreporting", version.ref = "googlePlayDeveloperReporting" } +google-auth-library-oauth2-http = { module = "com.google.auth:google-auth-library-oauth2-http", version.ref = "googleAuthLibraryOauth2Http" } +google-truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } +hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } +hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } +java-inject = { module = "javax.inject:javax.inject", version.ref = "javaxInjectVersion" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +kotlin-composeGradlePlugin = { group = "org.jetbrains.kotlin", name = "compose-compiler-gradle-plugin", version.ref = "kotlin" } +kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } +kotlin-logging-jvm = { group = "io.github.oshai", name = "kotlin-logging-jvm", version.ref = "kotlinLogging" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } +kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxImmutable" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" } +kotlinx-serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } +ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } +ktor-client-android = { group = "io.ktor", name = "ktor-client-android", version.ref = "ktor" } +ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } +leakcanary-android = { group = "com.squareup.leakcanary", name = "leakcanary-android", version.ref = "leakcanary" } +mockk = { module = "io.mockk:mockk", version.ref = "mockkVersion" } +mockk-agent-jvm = { module = "io.mockk:mockk-agent-jvm", version.ref = "mockkVersion" } +mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "mockwebserver" } +okhttp3-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttpLogging" } +play-integrity = { group = "com.google.android.play", name = "integrity", version.ref = "playIntegrity" } +plugin-detekt = { group = "dev.detekt", name = "detekt-gradle-plugin", version.ref = "detekt" } +retrofit2 = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit2" } +retrofit2-kotlinx-serialization-converter = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit2" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +screenshot-validation-api = { group = "com.android.tools.screenshot", name = "screenshot-validation-api", version.ref = "screenshot" } +room3-compiler = { group = "androidx.room3", name = "room3-compiler", version.ref = "room3" } +room3-gradlePlugin = { group = "androidx.room3", name = "room3-gradle-plugin", version.ref = "room3" } +room3-paging = { group = "androidx.room3", name = "room3-paging", version.ref = "room3" } +room3-runtime = { group = "androidx.room3", name = "room3-runtime", version.ref = "room3" } +room3-testing = { group = "androidx.room3", name = "room3-testing", version.ref = "room3" } +sentry-android = { group = "io.sentry", name = "sentry-android", version.ref = "sentry" } +sentry-compose-android = { group = "io.sentry", name = "sentry-compose-android", version.ref = "sentry" } +sentry-kotlin-compiler-plugin = { group = "io.sentry", name = "sentry-kotlin-compiler-plugin", version.ref = "sentryPlugin" } +slf4j-simple = { group = "org.slf4j", name = "slf4j-simple", version.ref = "slf4j" } +sqlcipher-android = { group = "net.zetetic", name = "sqlcipher-android", version.ref = "sqlcipher" } +turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } + +[bundles] +compose = [ + "androidx-compose-ui", + "androidx-compose-ui-tooling-preview", + "androidx-compose-material3", + "androidx-compose-foundation", + "androidx-compose-ui-tooling" +] + +navigation3 = [ + "androidx-navigation3-compose", + "androidx-compose-material3-adaptive-navigation3" +] + +adaptive = [ + "androidx-compose-material3-adaptive", + "androidx-compose-material3-adaptive-layout", + "androidx-compose-material3-adaptive-navigation", + "androidx-compose-material3-adaptive-navigation-suite" +] + +unit-test = [ + "junit", + "kotlin-test-junit", + "kotlinx-coroutines-test", + "turbine", + "google-truth" +] + +android-test = [ + "androidx-junit", + "androidx-espresso-core", + "androidx-compose-ui-test-junit4" +] + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +android-test = { id = "com.android.test", version.ref = "agp" } +androidx-baselineprofile = { id = "androidx.baselineprofile", version.ref = "baselineprofile" } +androidx-room3 = { id = "androidx.room3", version.ref = "room3" } +compose-stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version.ref = "composeStabilityAnalyzer" } +detekt = { id = "dev.detekt", version.ref = "detekt" } +firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlyticsPlugin" } +google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } +screenshot = { id = "com.android.compose.screenshot", version.ref = "screenshot" } +sentry-android = { id = "io.sentry.android.gradle", version.ref = "sentryPlugin" } +sentry-kotlin-compiler = { id = "io.sentry.kotlin.compiler.gradle", version.ref = "sentryPlugin" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } + +# Convention plugins (no version, resolved from build-logic) +app-android-application = { id = "app.android.application" } +app-android-application-baseline = { id = "app.android.application.baseline" } +app-android-application-compose = { id = "app.android.application.compose" } +app-android-application-jacoco = { id = "app.android.application.jacoco" } +app-android-feature = { id = "app.android.feature" } +app-android-library = { id = "app.android.library" } +app-android-library-compose = { id = "app.android.library.compose" } +app-android-library-jacoco = { id = "app.android.library.jacoco" } +app-android-lint = { id = "app.android.lint" } +app-android-room = { id = "app.android.room" } +app-android-test = { id = "app.android.test" } +app-detekt = { id = "app.detekt" } +app-firebase = { id = "app.firebase" } +app-hilt = { id = "app.hilt" } +app-jvm-library = { id = "app.jvm.library" } +app-kotlin-serialization = { id = "app.kotlin.serialization" } +app-play-vitals = { id = "app.play.vitals" } +app-sentry = { id = "app.sentry" } +app-spotless = { id = "app.spotless" } \ No newline at end of file diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/proguard-rules.pro.template b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/proguard-rules.pro.template new file mode 100644 index 000000000..28fe278e2 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/proguard-rules.pro.template @@ -0,0 +1,269 @@ +# ============================================================================== +# ProGuard / R8 Rules Template +# ============================================================================== +# Source of truth for all keep rules. Copy to app/proguard-rules.pro and +# adjust com.example.* package names to match your project. +# +# Most AndroidX and Jetpack libraries ship their own consumer rules inside +# the AAR/JAR - only add manual rules when the library docs say so, or when +# R8 full-mode requires it. +# ============================================================================== + + +# ============================================================================== +# 1. GENERAL / PROJECT-WIDE +# ============================================================================== + +# Keep source file names and line numbers for crash reports +-renamesourcefileattribute SourceFile +-keepattributes SourceFile,LineNumberTable + +# Remove Android logging in release builds +-assumenosideeffects class android.util.Log { + public static int v(...); + public static int d(...); + public static int i(...); + public static int w(...); +} + +# Obfuscation hardening +-repackageclasses '' +-allowaccessmodification + + +# ============================================================================== +# 2. KOTLIN / COROUTINES +# ============================================================================== +# kotlinx-coroutines ships its own rules; these suppress residual warnings. + +-dontwarn kotlinx.coroutines.** + + +# ============================================================================== +# 3. KOTLINX-SERIALIZATION +# ============================================================================== +# The library bundles rules since 1.6+, but R8 full-mode may still strip +# classes that are only referenced via generics (e.g. List). + +-keepattributes *Annotation*, InnerClasses +-dontnote kotlinx.serialization.AnnotationsKt + +# Keep companion objects and serializer() for every @Serializable class +-if @kotlinx.serialization.Serializable class ** +-keepclassmembers class <1> { + static <1>$Companion Companion; +} + +-if @kotlinx.serialization.Serializable class ** { + static **$* *; +} +-keepclassmembers class <2>$<3> { + kotlinx.serialization.KSerializer serializer(...); +} + +# Keep generated serializers +-if @kotlinx.serialization.Serializable class ** +-keep class <1>$$serializer { *; } + +# Keep project data/domain models used with serialization +-keep class com.example.core.domain.model.** { *; } +-keepclassmembers class com.example.core.domain.model.** { + ; +} + + +# ============================================================================== +# 4. RETROFIT +# ============================================================================== +# Retrofit uses reflection on generic parameters, annotations, and Proxy. +# These rules are from the official retrofit2.pro plus R8 full-mode fixes. + +-keepattributes Signature, InnerClasses, EnclosingMethod +-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations +-keepattributes AnnotationDefault + +# Retain service method parameters +-keepclassmembers,allowshrinking,allowobfuscation interface * { + @retrofit2.http.* ; +} + +# R8 full-mode: keep Retrofit interfaces (created via Proxy, invisible to R8) +-if interface * { @retrofit2.http.* ; } +-keep,allowobfuscation interface <1> + +# Keep inherited service interfaces +-if interface * { @retrofit2.http.* ; } +-keep,allowobfuscation interface * extends <1> + +# Suspend function continuations (R8 full-mode strips generic signatures) +-keep,allowoptimization,allowshrinking,allowobfuscation class kotlin.coroutines.Continuation + +# Return types referenced only in generic signatures +-if interface * { @retrofit2.http.* public *** *(...); } +-keep,allowoptimization,allowshrinking,allowobfuscation class <3> + +-keep,allowoptimization,allowshrinking,allowobfuscation class retrofit2.Response + +-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement +-dontwarn javax.annotation.** +-dontwarn kotlin.Unit +-dontwarn retrofit2.KotlinExtensions +-dontwarn retrofit2.KotlinExtensions$* + + +# ============================================================================== +# 5. OKHTTP +# ============================================================================== +# OkHttp 5.x ships its own rules. These suppress residual warnings only. + +-dontwarn org.conscrypt.** +-dontwarn org.bouncycastle.** +-dontwarn org.openjsse.** +-dontwarn okhttp3.internal.platform.** + + +# ============================================================================== +# 6. KTOR (if used) +# ============================================================================== + +-dontwarn org.slf4j.** +-dontwarn io.ktor.** + + +# ============================================================================== +# 7. ROOM 3 +# ============================================================================== +# Room uses codegen (KSP), not reflection - no manual rules needed in most +# cases. Add these if you reference Room types via reflection or custom logic. + +-keep class * extends androidx.room3.RoomDatabase +-keep @androidx.room3.Entity class * +-dontwarn androidx.room3.paging.** + + +# ============================================================================== +# 8. HILT / DAGGER +# ============================================================================== +# Hilt ships its own consumer rules. These are supplemental for edge cases. + +-dontwarn dagger.hilt.internal.** + +# Keep project-level DI setup (adjust package) +-keep class com.example.di.** { *; } + + +# ============================================================================== +# 9. FIREBASE +# ============================================================================== +# Firebase SDKs ship their own rules. These ensure readable crash reports. + +-keepattributes SourceFile,LineNumberTable +-keep public class * extends java.lang.Exception + + +# ============================================================================== +# 10. SENTRY +# ============================================================================== +# Sentry Gradle plugin handles mapping uploads automatically. +# These suppress residual warnings. + +-dontwarn io.sentry.android.timber.** + + +# ============================================================================== +# 11. COIL 3 +# ============================================================================== +# Coil 3 ships its own rules. Add these only if you hit service-loader issues +# in non-R8 builds (ProGuard). + +# -keep class * extends coil3.util.DecoderServiceLoaderTarget { *; } +# -keep class * extends coil3.util.FetcherServiceLoaderTarget { *; } + + +# ============================================================================== +# 12. ANDROIDX SECURITY-CRYPTO (EncryptedSharedPreferences) +# ============================================================================== +# Tink (underlying crypto lib) triggers missing-class warnings for error-prone +# annotations that are compile-time only. + +-dontwarn com.google.errorprone.annotations.** + + +# ============================================================================== +# 13. SQLCIPHER (if used) +# ============================================================================== + +-keep class net.zetetic.database.** { *; } +-keepclasseswithmembernames class * { + native ; +} + + +# ============================================================================== +# 14. PLAY INTEGRITY (if used) +# ============================================================================== +# The Play Core library ships its own rules. Suppress residual warnings. + +-dontwarn com.google.android.play.core.** + + +# ============================================================================== +# 15. COMPOSE +# ============================================================================== +# Compose compiler generates code - no manual rules needed for most cases. +# Keep stability annotations so R8 doesn't strip them - they control +# recomposition skipping at runtime via Strong Skipping Mode. + +-keep @androidx.compose.runtime.Stable class ** +-keep @androidx.compose.runtime.Immutable class ** +-keepclassmembers class * { + @androidx.compose.runtime.Stable ; +} + + +# ============================================================================== +# 16. PAGING +# ============================================================================== +# Paging ships its own rules. + +-dontwarn androidx.paging.** + + +# ============================================================================== +# 17. SECURITY HARDENING +# ============================================================================== +# Keep crypto and security classes that use reflection or JCA providers. +# Adjust package names to match your project. + +-keep class com.example.core.data.crypto.** { *; } +-keep class com.example.core.data.security.** { *; } + + +# ============================================================================== +# 18. ENUM / PARCELABLE / SERIALIZABLE +# ============================================================================== + +-keepclassmembers enum * { + public static **[] values(); + public static ** valueOf(java.lang.String); +} + +-keepclassmembers class * implements android.os.Parcelable { + public static final ** CREATOR; +} + + +# ============================================================================== +# 19. DEBUGGING SHRUNK BUILDS +# ============================================================================== +# To diagnose what R8 removes, use these Gradle flags during development: +# +# ./gradlew assembleRelease -Pandroid.enableR8.fullMode=true +# +# Check build/outputs/mapping/release/mapping.txt for the full mapping. +# Use retrace to decode obfuscated stack traces: +# +# retrace mapping.txt stacktrace.txt +# +# Upload mapping.txt to Firebase Crashlytics / Sentry for production decoding. +# Both the Firebase and Sentry Gradle plugins handle this automatically. diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/settings.gradle.kts.template b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/settings.gradle.kts.template new file mode 100644 index 000000000..4cb64ef6f --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/assets/settings.gradle.kts.template @@ -0,0 +1,122 @@ +//settings.gradle.kts +pluginManagement { + includeBuild("build-logic") + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + + // Uncomment if using libraries from JitPack + // maven { url = uri("https://jitpack.io") } + } + + versionCatalogs { + create("libs") { + from(files("gradle/libs.versions.toml")) + } + } +} + +rootProject.name = "{{PROJECT_NAME}}" + +// Type-safe project accessors are enabled by default in Gradle 9+ + +// Also configure in gradle.properties: +// org.gradle.configuration-cache=true +// org.gradle.caching=true +// org.gradle.parallel=true + +// App module - Navigation coordination, DI setup, app entry point +include(":app") + +// Feature modules - Self-contained features with clear boundaries +// include(":feature-auth") +// include(":feature-home") +// include(":feature-profile") +// include(":feature-settings") + +// Core modules - Shared library code with strict dependency rules +include(":core:domain") // Pure Kotlin: Use Cases, Repository interfaces, Domain models +include(":core:data") // Data layer: Repository implementations, DataSources, Data models +include(":core:ui") // Shared UI components, themes, base ViewModels +include(":core:network") // Retrofit, API models, network utilities +include(":core:database") // Room DAOs, entities, migrations +include(":core:datastore") // Preferences storage (DataStore) +include(":core:common") // Shared utilities, extensions, dispatchers +include(":core:testing") // Test utilities, test doubles, test rules + +// Configure build optimization for multi-module projects +configureBuildOptimization() + +/** + * Configures build optimization settings for our modular architecture + */ +fun configureBuildOptimization() { + // Set consistent build file names + gradle.settingsEvaluated { + for (project in projects) { + project.setBuildFileName("build.gradle.kts") + } + } + + // Configure project structure validation + gradle.projectsLoaded { + validateProjectStructure() + } +} + +/** + * Validates that our modular architecture rules are followed + */ +fun validateProjectStructure() { + val projects = gradle.rootProject.allprojects + + // Validate no feature-to-feature dependencies + projects.forEach { project -> + project.afterEvaluate { + val dependencies = configurations.getByName("implementation").allDependencies + dependencies.forEach { dependency -> + if (dependency is ProjectDependency) { + val dependencyPath = dependency.dependencyProject.path + val currentPath = project.path + + // Feature modules cannot depend on other feature modules + if (currentPath.startsWith(":feature-") && dependencyPath.startsWith(":feature-") && currentPath != dependencyPath) { + logger.warn("⚠️ VIOLATION: Feature module $currentPath depends on feature module $dependencyPath") + logger.warn(" This violates our architecture rule: NO feature-to-feature dependencies allowed") + } + + // Core:domain should have no Android dependencies (enforced in build.gradle.kts) + if (currentPath == ":core:domain") { + // This is validated in the core:domain build file + } + + // Core:data should depend on core:domain + if (currentPath == ":core:data" && !dependencyPath.startsWith(":core:")) { + logger.warn("⚠️ Core:data should only depend on other core modules") + } + } + } + } + } +} \ No newline at end of file diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-accessibility.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-accessibility.md new file mode 100644 index 000000000..93c9d627a --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-accessibility.md @@ -0,0 +1,1526 @@ +# Android Accessibility (Compose) + +Required target: **WCAG 2.2 Level AA** plus the Android-specific rules below (48dp touch targets, TalkBack semantics, Material 3 contrast tokens). WCAG 2.2 is backwards-compatible with 2.1; every 2.1 AA criterion still applies. + +## Table of Contents +1. [WCAG 2.2 Criteria That Apply Here](#wcag-22-criteria-that-apply-here) +2. [Semantic Properties](#semantic-properties) +3. [Touch Target Sizes](#touch-target-sizes) +4. [Screen Reader Navigation](#screen-reader-navigation) +5. [Color & Visual Accessibility](#color--visual-accessibility) +6. [Focus Management](#focus-management) +7. [Common Patterns](#common-patterns) +8. [Testing Accessibility](#testing-accessibility) + +## WCAG 2.2 Criteria That Apply Here + +WCAG 2.2 adds nine success criteria on top of 2.1. Required on Android Compose: + +| Criterion | Rule | Where it is handled | +|-------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------| +| 2.4.11 Focus Not Obscured (Minimum) | A focused element must not be fully hidden by author-created overlays (bottom sheets, IME, snackbars). | `#focus-management`; apply `Modifier.imePadding()` and inset-aware Scaffolds (see `references/compose-patterns.md`). | +| 2.5.7 Dragging Movements | Every drag gesture must have a single-pointer alternative (tap, long-press, button). | Sliders, reorderable lists, maps, swipe-to-dismiss. Provide explicit buttons. | +| 2.5.8 Target Size (Minimum) | Interactive targets must be at least 24 × 24 CSS px. | Android's 48dp × 48dp rule is stricter; enforce 48dp. See `#touch-target-sizes`. | +| 3.2.6 Consistent Help | Help mechanisms (contact, chat, FAQ) must appear in the same relative order on every screen. | App-level navigation, not per-screen. | +| 3.3.7 Redundant Entry | Do not ask the user for the same info twice in one session. Prefill or pull from state. | Multi-step forms, signup-then-onboarding flows. | +| 3.3.8 Accessible Authentication (Minimum) | Do not require a cognitive test (puzzle, exact recall, captcha without alternative) unless another factor exists. Paste and autofill must work on password fields. | Login, signup, password reset. Use Credential Manager - see `references/android-security.md`. | + +Always-applicable 2.1 AA criteria still in force: **1.4.3 Contrast (Minimum)** and **2.4.7 Focus Visible** - see `#color--visual-accessibility` and `#focus-management`. + +## Semantic Properties + +Set semantics on every interactive composable. TalkBack reads only the semantics tree. + +### Content Description + +Required on every non-text interactive element (icons, image buttons, decorative-but-tappable surfaces). Set `contentDescription = null` only when an adjacent text label already conveys the action. + +```kotlin +// CORRECT: Descriptive, action-oriented +IconButton( + onClick = { onDeleteItem(item.id) }, + modifier = Modifier.semantics { + contentDescription = "Delete ${item.name}" + } +) { + Icon(painterResource(R.drawable.ic_delete), contentDescription = null) +} + +// CORRECT: Icon already has description +Icon( + painterResource(R.drawable.ic_home), + contentDescription = "Home" +) + +// WRONG: Missing description +Icon( + painterResource(R.drawable.ic_settings), + contentDescription = null // Only use null if parent has description +) + +// WRONG: Redundant description +Button(onClick = { }) { + Icon( + painterResource(R.drawable.ic_save), + contentDescription = "Save" // Redundant! Button already has "Save" text + ) + Text("Save") +} +``` + +**Rules:** +- **Always provide** `contentDescription` for icons, images, and custom graphics +- **Set to null** if the element is decorative or its parent already describes it +- **Be specific**: "Delete Shopping List" not "Delete" +- **Include state**: "Favorite, added" not just "Favorite" + +### Label copy (TalkBack) + +TalkBack already announces the **role** (button, image). Labels should describe **purpose**, not control type. + +| Use | Avoid | +|-------------------------|------------------------| +| "Save" | "Save button" | +| "Submit" | "Click here to submit" | +| "Profile photo of Alex" | "Image" or "Image 1" | +| "Delete message" | "Button" (generic) | + +Do not put "tap" or "click" in descriptions (input method varies). Keep labels **short** and **unique in context** (for example "Delete draft" vs "Delete message" when both exist). For **editable fields**, use Material `label` / `placeholder` semantics; do not duplicate the same text in `contentDescription` in a way that makes TalkBack repeat itself. + +### State Description + +Describes dynamic state changes for screen readers. + +```kotlin +@Composable +fun ToggleButton( + isEnabled: Boolean, + onToggle: () -> Unit, + label: String, + modifier: Modifier = Modifier +) { + Button( + onClick = onToggle, + modifier = modifier.semantics { + stateDescription = if (isEnabled) "Enabled" else "Disabled" + } + ) { + Text(label) + } +} + +// Usage example: Notification toggle +@Composable +fun NotificationToggle( + isEnabled: Boolean, + onToggleNotifications: (Boolean) -> Unit +) { + var enabled by remember { mutableStateOf(isEnabled) } + + Row( + modifier = Modifier + .fillMaxWidth() + .toggleable( + value = enabled, + onValueChange = { + enabled = it + onToggleNotifications(it) + }, + role = Role.Switch + ) + .padding(16.dp) + .semantics(mergeDescendants = true) { + stateDescription = if (enabled) { + "Notifications enabled" + } else { + "Notifications disabled" + } + }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Push Notifications", style = MaterialTheme.typography.bodyLarge) + Switch( + checked = enabled, + onCheckedChange = null // Handled by Row toggleable + ) + } +} +``` + +### Role Property + +Defines the semantic role of a composable for assistive technologies. + +```kotlin +import androidx.compose.ui.semantics.Role + +// Built-in roles +Button( + onClick = { }, + modifier = Modifier.semantics { role = Role.Button } // Implicit for Button +) { Text("Submit") } + +// Custom clickable with explicit role +Box( + modifier = Modifier + .clickable( + onClick = { navigateToProfile() }, + role = Role.Button // Announces as a button + ) + .semantics { contentDescription = "View profile" } +) { + ProfileAvatar() +} + +// Checkbox role +Row( + modifier = Modifier + .selectable( + selected = isSelected, + onClick = { onToggle() }, + role = Role.Checkbox + ) + .semantics(mergeDescendants = true) {} +) { + Checkbox(checked = isSelected, onCheckedChange = null) + Text("Accept terms and conditions") +} + +// Tab role for navigation +TabRow(selectedTabIndex = selectedTab) { + tabs.forEachIndexed { index, tab -> + Tab( + selected = selectedTab == index, + onClick = { onTabSelected(index) }, + modifier = Modifier.semantics { role = Role.Tab } + ) { + Text(tab.title) + } + } +} +``` + +**Available Roles:** +- `Role.Button` +- `Role.Checkbox` +- `Role.Switch` +- `Role.RadioButton` +- `Role.Tab` +- `Role.Image` +- `Role.DropdownList` + +### Custom Actions + +Provide additional actions for screen readers. + +```kotlin +@Composable +fun EmailListItem( + email: Email, + onMarkAsRead: () -> Unit, + onArchive: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier +) { + ListItem( + headlineContent = { Text(email.subject) }, + supportingContent = { Text(email.preview) }, + leadingContent = { + Icon( + painterResource( + if (email.isRead) R.drawable.ic_mail_open + else R.drawable.ic_mail + ), + contentDescription = if (email.isRead) "Read" else "Unread" + ) + }, + modifier = modifier + .clickable { /* Open email */ } + .semantics { + // Custom actions accessible via TalkBack menu + customActions = listOf( + CustomAccessibilityAction("Mark as read") { + onMarkAsRead() + true + }, + CustomAccessibilityAction("Archive") { + onArchive() + true + }, + CustomAccessibilityAction("Delete") { + onDelete() + true + } + ) + } + ) +} +``` + +### Merge Descendants vs ClearAndSetSemantics + +Use `mergeDescendants = true` when you want to combine the semantics of child elements into a single announcement (e.g., a card with a title and subtitle). + +Use `clearAndSetSemantics` when you want to completely replace the semantics of child elements with a custom description, ignoring what the children would normally announce. + +```kotlin +// CORRECT: Merge card content for single announcement +@Composable +fun ArticleCard(article: Article, onClick: () -> Unit) { + Card( + onClick = onClick, + modifier = Modifier.semantics(mergeDescendants = true) { + // Screen reader will read this, plus any other semantics from children + // that aren't explicitly overridden here. + contentDescription = "${article.title}. ${article.author}. ${article.date}" + } + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text(article.title, style = MaterialTheme.typography.titleMedium) + Text(article.author, style = MaterialTheme.typography.bodySmall) + Text(article.date, style = MaterialTheme.typography.bodySmall) + } + } +} + +// CORRECT: Merge form label and input +@Composable +fun LabeledTextField( + label: String, + value: String, + onValueChange: (String) -> Unit, + error: String? = null, + modifier: Modifier = Modifier +) { + Column(modifier = modifier.semantics(mergeDescendants = true) {}) { + Text(label, style = MaterialTheme.typography.labelMedium) + OutlinedTextField( + value = value, + onValueChange = onValueChange, + isError = error != null, + modifier = Modifier.fillMaxWidth() + ) + if (error != null) { + Text( + text = error, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + } +} +``` + +### Clear Semantics + +Hide or override default semantics when needed. + +```kotlin +// CORRECT: Hide decorative image from screen readers +Image( + painterResource(R.drawable.decorative_pattern), + contentDescription = null, + modifier = Modifier.semantics { invisibleToUser() } +) + +// CORRECT: Clear default semantics for custom implementation +Box( + modifier = Modifier + .clearAndSetSemantics { + // Completely replace default semantics. Children are ignored. + contentDescription = "Custom rating: 4 out of 5 stars" + role = Role.Button + } + .clickable { showRatingDialog() } +) { + CustomStarRating(rating = 4) +} +``` + +### Semantic Keys + +Compose uses `SemanticsPropertyKey` to define semantic properties. You can create custom keys for specific use cases, though the built-in ones (`contentDescription`, `stateDescription`, `role`, etc.) cover most needs. + +```kotlin +// Define a custom semantic key +val IsFavoriteKey = SemanticsPropertyKey("IsFavorite") + +// Apply it +Modifier.semantics { + set(IsFavoriteKey, true) +} +``` + +## Touch Target Sizes + +All interactive elements must have a minimum touch target size of **48dp × 48dp** for accessibility. + +### Minimum Touch Targets + +```kotlin +// CORRECT: Sufficient touch target +IconButton( + onClick = { onDeleteClick() } // IconButton defaults to 48dp +) { + Icon(painterResource(R.drawable.ic_delete), contentDescription = "Delete") +} + +// WRONG: Too small +Icon( + painterResource(R.drawable.ic_settings), + contentDescription = "Settings", + modifier = Modifier.clickable { } // Only 24dp by default +) + +// CORRECT: Explicit padding to meet minimum +Icon( + painterResource(R.drawable.ic_settings), + contentDescription = "Settings", + modifier = Modifier + .clickable { onSettingsClick() } + .size(24.dp) + .padding(12.dp) // Total: 48dp +) + +// CORRECT: Minimum touch target with custom size +Box( + modifier = Modifier + .clickable { onItemClick() } + .sizeIn(minWidth = 48.dp, minHeight = 48.dp) // Enforce minimum + .padding(8.dp), + contentAlignment = Alignment.Center +) { + Icon( + painterResource(R.drawable.ic_custom), + contentDescription = "Custom action", + modifier = Modifier.size(32.dp) + ) +} +``` + +### Spacing Between Targets + +Maintain adequate spacing between interactive elements. + +```kotlin +// CORRECT: Proper spacing between actions +Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) // Minimum 8dp spacing +) { + IconButton(onClick = { onFavorite() }) { + Icon(painterResource(R.drawable.ic_favorite), "Add to favorites") + } + IconButton(onClick = { onShare() }) { + Icon(painterResource(R.drawable.ic_share), "Share") + } + IconButton(onClick = { onDownload() }) { + Icon(painterResource(R.drawable.ic_download), "Download") + } +} + +// WRONG: Actions too close together +Row(modifier = Modifier.padding(16.dp)) { + Icon( + painterResource(R.drawable.ic_favorite), + contentDescription = "Favorite", + modifier = Modifier.clickable { } + ) + Icon( // Too close to previous icon! + painterResource(R.drawable.ic_share), + contentDescription = "Share", + modifier = Modifier.clickable { } + ) +} +``` + +### Testing Touch Targets + +```kotlin +// In tests: Verify touch target sizes +@Test +fun deleteButton_meetsMinimumTouchTarget() { + composeTestRule.setContent { + DeleteButton(onDelete = {}) + } + + composeTestRule + .onNodeWithContentDescription("Delete") + .assertWidthIsAtLeast(48.dp) + .assertHeightIsAtLeast(48.dp) +} +``` + +## Screen Reader Navigation + +Control how TalkBack navigates and announces your UI. + +### Traversal Order + +Control the order in which screen readers navigate elements. + +```kotlin +@Composable +fun ProfileHeader( + name: String, + bio: String, + avatarUrl: String, + onEditProfile: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + // Avatar (traversal order: 1) + AsyncImage( + model = avatarUrl, + contentDescription = "Profile picture", + modifier = Modifier + .size(64.dp) + .clip(CircleShape) + .semantics { traversalIndex = 0f } // Visit first + ) + + // Name and bio (traversal order: 2) + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp) + .semantics { traversalIndex = 1f } // Visit second + ) { + Text(name, style = MaterialTheme.typography.titleLarge) + Text(bio, style = MaterialTheme.typography.bodyMedium) + } + + // Edit button (traversal order: 3) + IconButton( + onClick = onEditProfile, + modifier = Modifier.semantics { traversalIndex = 2f } // Visit last + ) { + Icon(painterResource(R.drawable.ic_edit), "Edit profile") + } + } +} +``` + +### Heading Structure + +Define content hierarchy for screen readers. + +```kotlin +@Composable +fun ArticleScreen(article: Article) { + LazyColumn { + item { + // H1: Article title + Text( + text = article.title, + style = MaterialTheme.typography.headlineLarge, + modifier = Modifier.semantics { heading() } + ) + } + + item { + // Metadata (not a heading) + Text("${article.author} · ${article.date}") + } + + items(article.sections) { section -> + Column { + // H2: Section titles + Text( + text = section.title, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.semantics { heading() } + ) + Text(section.content) + } + } + } +} +``` + +### Live Region Announcements + +Announce dynamic content changes to screen readers. + +**Modes:** In `Modifier.semantics`, `liveRegion = LiveRegionMode.Polite` queues announcements when the user is idle; `LiveRegionMode.Assertive` interrupts — reserve it for critical errors. Default to **polite** live regions on the composable that changed, or rely on **stateDescription** / **error** semantics so TalkBack picks up updates without extra noise. + +**Avoid** firing raw `AccessibilityEvent.TYPE_ANNOUNCEMENT` for every minor UI tick. Drive updates through semantics; emit one-off announcements only when no stable node can carry the change. + +```kotlin +@Composable +fun ToastMessage(message: String?, onDismiss: () -> Unit) { + val context = LocalContext.current + + LaunchedEffect(message) { + message?.let { + // Announce to screen reader immediately + val announcement = android.view.accessibility.AccessibilityEvent.obtain( + android.view.accessibility.AccessibilityEvent.TYPE_ANNOUNCEMENT + ) + announcement.text.add(it) + context.findActivity()?.let { activity -> + activity.window.decorView.sendAccessibilityEvent(announcement) + } + + delay(3.seconds) + onDismiss() + } + } + + if (message != null) { + Snackbar( + modifier = Modifier.semantics { + liveRegion = LiveRegionMode.Polite + } + ) { + Text(message) + } + } +} + +// ViewModel announcing state changes +@HiltViewModel +class ItemsViewModel @Inject constructor( + private val repository: ItemsRepository +) : ViewModel() { + private val _toastMessage = MutableSharedFlow(replay = 0) + val toastMessage: SharedFlow = _toastMessage.asSharedFlow() + + fun deleteItem(itemId: String) { + viewModelScope.launch { + repository.deleteItem(itemId) + .onSuccess { + _toastMessage.emit("Item deleted") // Announced by screen reader + } + .onFailure { + _toastMessage.emit("Failed to delete item") + } + } + } +} +``` + +### Skip to Content + +Allow users to bypass repetitive navigation. + +```kotlin +@Composable +fun MainScreen( + showTopBar: Boolean = true, + topBarContent: @Composable () -> Unit = {}, + content: @Composable () -> Unit +) { + Scaffold( + topBar = { + if (showTopBar) { + Column { + topBarContent() + + // Skip to main content button (hidden visually) + TextButton( + onClick = { /* Focus on content */ }, + modifier = Modifier + .semantics { contentDescription = "Skip to main content" } + .size(1.dp) // Visually hidden but accessible + .alpha(0f) + ) { + Text("Skip to content") + } + } + } + } + ) { padding -> + Box( + modifier = Modifier + .padding(padding) + .semantics { heading() } // Mark main content + ) { + content() + } + } +} +``` + +## Color & Visual Accessibility + +Ensure sufficient color contrast and don't rely on color alone. + +### Color Contrast Requirements + +**WCAG 2.2 Level AA (1.4.3 Contrast Minimum, 1.4.11 Non-text Contrast):** +- **Normal text:** 4.5:1 contrast ratio +- **Large text** (18pt+/14pt+ bold): 3:1 contrast ratio +- **UI components and graphical objects:** 3:1 contrast ratio + +```kotlin +@Composable +fun AccessibleButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + // Material 3 automatically provides sufficient contrast + Button( + onClick = onClick, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, // Sufficient contrast + contentColor = MaterialTheme.colorScheme.onPrimary + ), + modifier = modifier + ) { + Text(text) + } +} + +// WRONG: Insufficient contrast +@Composable +fun PoorContrastText() { + Text( + text = "Hard to read", + color = Color(0xFFCCCCCC), // Light gray on white background + modifier = Modifier.background(Color.White) + ) +} + +// CORRECT: Check contrast programmatically +@Composable +fun DynamicContrastText( + text: String, + backgroundColor: Color +) { + val textColor = if (backgroundColor.luminance() > 0.5) { + Color.Black // Dark text on light background + } else { + Color.White // Light text on dark background + } + + Text( + text = text, + color = textColor, + modifier = Modifier.background(backgroundColor) + ) +} +``` + +### Don't Rely on Color Alone + +Use multiple indicators (color + icon + text). + +```kotlin +// WRONG: Color only +@Composable +fun StatusBadge(status: Status) { + Box( + modifier = Modifier + .size(12.dp) + .background( + when (status) { + Status.Success -> Color.Green + Status.Error -> Color.Red + Status.Warning -> Color.Yellow + }, + CircleShape + ) + ) +} + +// CORRECT: Color + Icon + Text +@Composable +fun AccessibleStatusBadge(status: Status) { + val (icon, color, text) = when (status) { + Status.Success -> Triple(R.drawable.ic_check, Color.Green, "Success") + Status.Error -> Triple(R.drawable.ic_error, Color.Red, "Error") + Status.Warning -> Triple(R.drawable.ic_warning, Color.Yellow, "Warning") + } + + Row( + modifier = Modifier.semantics(mergeDescendants = true) { + contentDescription = "$text status" + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + painterResource(icon), + contentDescription = null, + tint = color, + modifier = Modifier.size(16.dp) + ) + Text(text, style = MaterialTheme.typography.labelSmall) + } +} + +// CORRECT: Form validation with multiple indicators +@Composable +fun AccessibleTextField( + value: String, + onValueChange: (String) -> Unit, + label: String, + error: String? = null, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label) }, + isError = error != null, + trailingIcon = if (error != null) { + { + Icon( + painterResource(R.drawable.ic_error), + contentDescription = "Error", + tint = MaterialTheme.colorScheme.error + ) + } + } else null, + modifier = Modifier + .fillMaxWidth() + .semantics { + if (error != null) { + // Announce error state + error(error) + } + } + ) + + if (error != null) { + Row( + modifier = Modifier.padding(start = 16.dp, top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painterResource(R.drawable.ic_error), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp) + ) + Text( + text = error, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + } + } +} +``` + +### Dark Mode & High Contrast + +Support system-wide accessibility settings. + +```kotlin +@Composable +fun ThemedContent() { + // Material 3 automatically handles dark/light theme + MaterialTheme( + colorScheme = if (isSystemInDarkTheme()) { + darkColorScheme() + } else { + lightColorScheme() + } + ) { + // Content automatically adapts + Surface( + color = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground + ) { + AppContent() + } + } +} + +// Check for high contrast mode (Android 14+) +@Composable +fun HighContrastAwareButton( + text: String, + onClick: () -> Unit +) { + val configuration = LocalConfiguration.current + val isHighContrast = remember(configuration) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + configuration.isScreenWideColorGamut // Proxy for high contrast + } else { + false + } + } + + Button( + onClick = onClick, + colors = ButtonDefaults.buttonColors( + containerColor = if (isHighContrast) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.primaryContainer + } + ) + ) { + Text(text) + } +} +``` + +## Focus Management + +Control keyboard and accessibility focus. + +### Focus Order + +```kotlin +@Composable +fun LoginForm( + email: String, + onEmailChange: (String) -> Unit, + password: String, + onPasswordChange: (String) -> Unit, + onLogin: () -> Unit +) { + val focusManager = LocalFocusManager.current + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // First field focuses automatically + OutlinedTextField( + value = email, + onValueChange = onEmailChange, + label = { Text("Email") }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next + ), + keyboardActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Down) } + ), + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = password, + onValueChange = onPasswordChange, + label = { Text("Password") }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + focusManager.clearFocus() + onLogin() + } + ), + modifier = Modifier.fillMaxWidth() + ) + + Button( + onClick = { + focusManager.clearFocus() + onLogin() + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Login") + } + } +} +``` + +### Request Focus + +```kotlin +@Composable +fun SearchScreen() { + val focusRequester = remember { FocusRequester() } + + Column { + OutlinedTextField( + value = "", + onValueChange = { }, + label = { Text("Search") }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + ) + + // Auto-focus search field when screen appears + LaunchedEffect(Unit) { + delay(100.milliseconds) // Small delay for layout + focusRequester.requestFocus() + } + } +} +``` + +### Focus Indicators + +Ensure visible focus indicators for keyboard navigation. + +```kotlin +@Composable +fun AccessibleClickableCard( + title: String, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + onClick = onClick, + modifier = modifier.focusable(), // Shows focus indicator + border = BorderStroke( + width = 2.dp, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) + ) + ) { + Text( + text = title, + modifier = Modifier.padding(16.dp) + ) + } +} +``` + +## Common Patterns + +Accessibility patterns for common UI components. + +### Lists + +```kotlin +@Composable +fun AccessibleUserList( + users: List, + onUserClick: (User) -> Unit +) { + LazyColumn( + modifier = Modifier.semantics { + // Announce list size to screen readers + contentDescription = "${users.size} users" + } + ) { + items(users) { user -> + ListItem( + headlineContent = { Text(user.name) }, + supportingContent = { Text(user.email) }, + leadingContent = { + AsyncImage( + model = user.avatarUrl, + contentDescription = "${user.name}'s profile picture", + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + ) + }, + modifier = Modifier + .clickable { onUserClick(user) } + .semantics(mergeDescendants = true) { + contentDescription = "${user.name}, ${user.email}" + } + ) + } + } +} +``` + +### Dialogs + +```kotlin +@Composable +fun AccessibleDeleteDialog( + itemName: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + "Delete item", + modifier = Modifier.semantics { heading() } + ) + }, + text = { + Text( + "Are you sure you want to delete \"$itemName\"? This action cannot be undone.", + modifier = Modifier.semantics { + // Make dialog content clear to screen readers + liveRegion = LiveRegionMode.Polite + } + ) + }, + confirmButton = { + TextButton( + onClick = { + onConfirm() + onDismiss() + }, + modifier = Modifier.semantics { + contentDescription = "Confirm delete $itemName" + } + ) { + Text("Delete") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + modifier = Modifier.semantics { + // Announce dialog appearance + liveRegion = LiveRegionMode.Polite + } + ) +} +``` + +### Bottom Navigation + +```kotlin +@Composable +fun AccessibleBottomNavigation( + selectedRoute: String, + onNavigate: (String) -> Unit +) { + NavigationBar { + NavigationBarItem( + selected = selectedRoute == "home", + onClick = { onNavigate("home") }, + icon = { + Icon( + painterResource(R.drawable.ic_home), + contentDescription = null // Label provides description + ) + }, + label = { Text("Home") }, + modifier = Modifier.semantics(mergeDescendants = true) { + contentDescription = if (selectedRoute == "home") { + "Home, selected" + } else { + "Home" + } + } + ) + + NavigationBarItem( + selected = selectedRoute == "search", + onClick = { onNavigate("search") }, + icon = { + Icon( + painterResource(R.drawable.ic_search), + contentDescription = null + ) + }, + label = { Text("Search") }, + modifier = Modifier.semantics(mergeDescendants = true) { + contentDescription = if (selectedRoute == "search") { + "Search, selected" + } else { + "Search" + } + } + ) + + NavigationBarItem( + selected = selectedRoute == "profile", + onClick = { onNavigate("profile") }, + icon = { + Icon( + painterResource(R.drawable.ic_person), + contentDescription = null + ) + }, + label = { Text("Profile") }, + modifier = Modifier.semantics(mergeDescendants = true) { + contentDescription = if (selectedRoute == "profile") { + "Profile, selected" + } else { + "Profile" + } + } + ) + } +} +``` + +### Forms with Validation + +```kotlin +@Composable +fun AccessibleRegistrationForm( + viewModel: RegistrationViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val focusManager = LocalFocusManager.current + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "Create Account", + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.semantics { heading() } + ) + + // Name field + OutlinedTextField( + value = uiState.name, + onValueChange = { viewModel.onNameChange(it) }, + label = { Text("Full Name *") }, + isError = uiState.nameError != null, + supportingText = uiState.nameError?.let { + { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painterResource(R.drawable.ic_error), + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.error + ) + Text(it) + } + } + }, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next + ), + keyboardActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Down) } + ), + modifier = Modifier + .fillMaxWidth() + .semantics { + if (uiState.nameError != null) { + error(uiState.nameError!!) + } + } + ) + + // Email field + OutlinedTextField( + value = uiState.email, + onValueChange = { viewModel.onEmailChange(it) }, + label = { Text("Email *") }, + isError = uiState.emailError != null, + supportingText = uiState.emailError?.let { + { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painterResource(R.drawable.ic_error), + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.error + ) + Text(it) + } + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next + ), + keyboardActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Down) } + ), + modifier = Modifier + .fillMaxWidth() + .semantics { + if (uiState.emailError != null) { + error(uiState.emailError!!) + } + } + ) + + // Password field with strength indicator + OutlinedTextField( + value = uiState.password, + onValueChange = { viewModel.onPasswordChange(it) }, + label = { Text("Password *") }, + visualTransformation = PasswordVisualTransformation(), + isError = uiState.passwordError != null, + supportingText = { + Column { + if (uiState.passwordError != null) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painterResource(R.drawable.ic_error), + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.error + ) + Text(uiState.passwordError!!) + } + } else { + Text("Password strength: ${uiState.passwordStrength}") + } + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + focusManager.clearFocus() + viewModel.onSubmit() + } + ), + modifier = Modifier + .fillMaxWidth() + .semantics { + if (uiState.passwordError != null) { + error(uiState.passwordError!!) + } else { + stateDescription = "Password strength: ${uiState.passwordStrength}" + } + } + ) + + // Submit button + Button( + onClick = { + focusManager.clearFocus() + viewModel.onSubmit() + }, + enabled = uiState.isValid && !uiState.isLoading, + modifier = Modifier + .fillMaxWidth() + .semantics { + if (!uiState.isValid) { + stateDescription = "Form has errors. Please fix them to continue." + } + } + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text("Create Account") + } + } + } +} +``` + +## Testing Accessibility + +Test accessibility with automated tools and manual verification. + +### Compose Testing + +```kotlin +@Test +fun loginButton_hasAccessibleTouchTarget() { + composeTestRule.setContent { + LoginScreen() + } + + composeTestRule + .onNodeWithText("Login") + .assertWidthIsAtLeast(48.dp) + .assertHeightIsAtLeast(48.dp) +} + +@Test +fun deleteIcon_hasContentDescription() { + composeTestRule.setContent { + ItemCard(item = testItem, onDelete = {}) + } + + composeTestRule + .onNodeWithContentDescription("Delete ${testItem.name}") + .assertExists() +} + +@Test +fun formError_isAnnounced() { + composeTestRule.setContent { + EmailField( + value = "invalid", + onValueChange = {}, + error = "Invalid email address" + ) + } + + composeTestRule + .onNodeWithText("Email") + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.Error, + "Invalid email address" + ) + ) +} + +@Test +fun toggleButton_announcesState() { + var isEnabled by mutableStateOf(false) + + composeTestRule.setContent { + ToggleButton( + isEnabled = isEnabled, + onToggle = { isEnabled = !isEnabled }, + label = "Notifications" + ) + } + + // Initial state + composeTestRule + .onNodeWithText("Notifications") + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.StateDescription, + "Disabled" + ) + ) + + // After toggle + composeTestRule + .onNodeWithText("Notifications") + .performClick() + + composeTestRule + .onNodeWithText("Notifications") + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.StateDescription, + "Enabled" + ) + ) +} +``` + +### Espresso accessibility checks (instrumented) + +For **View-based** or hybrid screens, enable Espresso's built-in checks so basic a11y violations fail tests early. Add the `espresso-accessibility` artifact (see `references/testing.md` and your version catalog), then: + +```kotlin +import androidx.test.espresso.accessibility.AccessibilityChecks + +@Before +fun enableA11yChecks() { + AccessibilityChecks.enable() +} +``` + +Compose UI tests assert **semantics** directly for most flows. Use Espresso accessibility checks when `ActivityScenario` / `Espresso` or `AndroidView` interop still owns the surface under test. + +### Accessibility Scanner + +Use Android Accessibility Scanner for manual testing: + +```kotlin +// In debug builds, enable accessibility test mode +@Composable +fun AppContent() { + if (BuildConfig.DEBUG) { + // Enable test tags for accessibility scanner + CompositionLocalProvider( + LocalInspectionMode provides true + ) { + MainContent() + } + } else { + MainContent() + } +} +``` + +### Manual Testing Checklist + +**Test with TalkBack:** +1. Enable TalkBack in Settings → Accessibility +2. Navigate through each screen +3. Verify all interactive elements are announced +4. Check that images have proper descriptions +5. Confirm form errors are announced +6. Test custom actions in long-press menu + +**Test with Switch Access:** +1. Enable Switch Access in Settings → Accessibility +2. Navigate using keyboard or external switch +3. Verify all interactive elements are accessible +4. Check focus order makes sense + +**Test with Font Scaling:** +1. Settings → Display → Font size → Largest +2. Verify all text remains readable +3. Check that touch targets don't overlap +4. Ensure no text is cut off + +**Test Color Contrast:** +1. Enable high contrast mode (Android 14+) +2. Use Accessibility Scanner app +3. Verify contrast ratios meet WCAG 2.2 AA (1.4.3, 1.4.11) + +### Integration with CI/CD + +```kotlin +// In app/build.gradle.kts +android { + lint { + enable += setOf( + "ContentDescription", + "TouchTargetSizeCheck", + "TextContrastCheck", + "ClickableViewAccessibility" + ) + + // Treat accessibility issues as errors in CI + abortOnError = System.getenv("CI") == "true" + } +} +``` + +## Rules + +**Required:** +- Provide `contentDescription` for all icons and images +- Write concise labels (purpose, not "button" / "tap here") +- Ensure 48dp × 48dp minimum touch targets +- Use `mergeDescendants` to group related content +- Announce state changes with `stateDescription` +- Support dark mode and high contrast +- Test with TalkBack enabled + +**Forbidden:** +- Rely on color alone to convey information +- Use small touch targets (< 48dp) +- Ignore form validation error announcements +- Use `contentDescription` on decorative images +- Forget to test with accessibility services enabled +- Hardcode text (use string resources for i18n) + +## References + +- Official Android Accessibility: https://developer.android.com/guide/topics/ui/accessibility +- Compose Accessibility: https://developer.android.com/jetpack/compose/accessibility +- WCAG 2.2 Guidelines: https://www.w3.org/WAI/WCAG22/quickref/ +- WCAG 2.2 What's New: https://www.w3.org/WAI/standards-guidelines/wcag/new-in-22/ +- Material Design Accessibility: https://m3.material.io/foundations/accessible-design/overview +- TalkBack User Guide: https://support.google.com/accessibility/android/answer/6283677 diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-ci-cd.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-ci-cd.md new file mode 100644 index 000000000..f7c1792ae --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-ci-cd.md @@ -0,0 +1,117 @@ +# Android CI/CD and Play release + +Directives for **repo and CI files** an agent edits, versus **Play Console, credentials, and store policy** handled outside tracked Gradle and YAML. Signing env var **names** and `.gitignore` patterns: [android-security.md](android-security.md) → **CI/CD Security**. Build cache and convention plugins: [gradle-setup.md](gradle-setup.md). Vitals automation an agent wires in Gradle: [android-performance.md](android-performance.md#optional-play-vitals-observability-play-developer-reporting-api). + +## Agent vs outside-repo work + +| Work | Agent | Outside repo | +|----------------------------------------------------------------------------|-------|---------------------------------------------------------------| +| Edit Gradle so `bundleRelease` (or flavor task) produces `.aab` | Yes | Flavor dimensions match Play listing | +| Add or fix GitHub Actions / CI YAML for `detekt`, tests, `bundleRelease` | Yes | Repo secrets bound; workflow scope approved | +| Add `.gitignore` rules and remove committed keystores or password files | Yes | Keys created; secrets stored in CI or HSM | +| Choose next safe `versionCode` from Play history | No | Next value or API output supplied; agent wires injection only | +| Upload AAB, create release, set track, set rollout %, promote | No | Play Console or authenticated publish CLI | +| Complete Data safety, release notes, store listing text in Play | No | In-repo `CHANGELOG` / templates drafted on request only | +| Run `./gradlew` locally or in CI when the environment exposes Gradle + SDK | Yes | Network and secrets policy satisfied | +| Run `bundletool` when the binary is on disk and the tool is on `PATH` | Yes | `.aab` path and device spec JSON available | + +Stop: do not fabricate `versionCode`, signing passwords, Play service account JSON, or upload actions that require Console login. + +## Table of Contents + +1. [Ship artifact format](#ship-artifact-format) +2. [versionCode and versionName](#versioncode-and-versionname) +3. [Release signing boundaries](#release-signing-boundaries) +4. [Play Console tracks](#play-console-tracks) +5. [Staged rollout on production](#staged-rollout-on-production) +6. [Upload automation routing](#upload-automation-routing) +7. [CI job composition (release lane)](#ci-job-composition-release-lane) +8. [Internal sharing without Play Console](#internal-sharing-without-play-console) +9. [Release notes and policy surfaces](#release-notes-and-policy-surfaces) + +## Ship artifact format + +Required for Gradle: release automation builds an Android App Bundle (`.aab`) via `bundleRelease` or the correct flavored bundle task when the listing path is Google Play. + +Forbidden in repo config: default Play-bound `release` to a fat universal APK when the team distributes through Play with AAB support. + +Use when: sideloading, MDM, or non-Play channels - document `bundletool build-apks` or flavor-scoped APK tasks for upload handoff; agent adds Gradle wiring only where the project already uses APK outputs. + +## versionCode and versionName + +Play rejects uploads whose `versionCode` is not strictly greater than the max already accepted for that `applicationId`. An agent **never** picks the next integer from thin air. + +Required for CI files: once the next allowed `versionCode` is supplied (or a documented allocator such as CI build number offset is approved), inject it through `gradle.properties`, CI-generated props, or `build.gradle.kts` logic. + +Use `versionName` for human-readable labels in Gradle; do not encode Play ordering logic in `versionName` alone. + +Forbidden: merge two branches that both bump `versionCode` to the same value without resolving Play upload history first. + +## Release signing boundaries + +Required for repo hygiene: no `*.jks`, `*.keystore`, passwords, or `signing.properties` with secrets in tracked files; align with [android-security.md](android-security.md) → **CI/CD Security** and `.gitignore` there. + +Required for CI YAML: reference secret **names** (`KEYSTORE_PASSWORD`, etc.) only; never inline values. + +Forbidden: add production `signingConfig` blocks that embed passwords in source readable on fork clones. + +PR / topic branch workflows: use `assembleDebug` or unsigned `assembleRelease` patterns the project already uses; do not attach production signing to every pull request job. + +Create upload keys, enroll Play App Signing, and paste SHA-256 into `assetlinks.json` hosts outside Gradle ([android-navigation.md](android-navigation.md#where-to-get-the-sha-256)). + +## Play Console tracks + +Routing vocabulary for release policy; **no Console API calls from an agent unless the user explicitly runs a tool with credentials already configured.** + +| Track | Typical use | +|------------------|-----------------------------------------| +| Internal testing | Fast validation on Play-signed binaries | +| Closed testing | Named tester cohorts | +| Open testing | Public opt-in beta | +| Production | General availability after promotion | + +Default policy text: high-risk launches pass through internal or closed testing before production unless release management documents an exception. + +## Staged rollout on production + +Use when: blast radius must stay limited after production promotion. + +Required: open production below 100% first unless a written hotfix policy demands full rollout; raise percentage only after crash and ANR signals from reporters and vitals look stable. + +Agent-allowed in repo: document the team's rollout checklist in markdown; add links to vitals automation ([android-performance.md](android-performance.md#optional-play-vitals-observability-play-developer-reporting-api)) so signals are read before raising percentage. + +## Upload automation routing + +Agent-allowed: add `fastlane/Fastfile` skeletons, Gradle Play Publisher plugin declarations, or workflow steps **without** embedding JSON keys or passwords; use placeholder env names. + +| Approach | Agent wires | Runs outside repo | +|-------------------------------------------------------------|----------------------------------------------------------------------------------------------|----------------------------------------------| +| Manual Play Console upload | Documents that `.aab` path is the handoff artifact | Browser upload | +| fastlane (`supply`, `pilot`, etc.) | Ruby files, lane names, CI job shell that invokes `bundle exec fastlane` when env vars exist | Ruby deps, API JSON, publish lanes | +| Gradle Play Publisher (or other Play Developer API clients) | Plugin + task names in Gradle; CI step that calls the task | Service account, Play permissions, key in CI | + +Forbidden in CI design: publish tasks on arbitrary branch pushes without the same gates used on the protected integration branch. + +## CI job composition (release lane) + +Agent-executable when Gradle runs in the session: + +- `./gradlew detekt` (or project baseline per [code-quality.md](code-quality.md)). +- Unit tests; add or adjust instrumented smoke jobs only where the project already has emulator CI or the user supplies a runner. +- `./gradlew bundleRelease` (or flavored bundle) only after the above succeed in the same pipeline definition. + +Optional when `bundletool` exists and an `.aab` path is known: `bundletool validate` (or equivalent) in a workflow step enabled when ready. + +Native `.so` gates: reference [migration.md](migration.md#16-kb-memory-page-size-play-and-native-code); agent adds CI grep / script steps only if the repo already uses that pattern. + +## Internal sharing without Play Console + +Agent-allowed: document the exact `bundletool build-apks` invocation and device-spec JSON layout; add a `Makefile` or script target that wraps the command when paths are parameterized. + +## Release notes and policy surfaces + +Agent-allowed: draft `CHANGELOG.md` entries or in-repo release note snippets from merged PR titles. + +Play store listing text, Data safety questionnaire, and policy surfaces in Console: [android-security.md](android-security.md) → **Play Console Data Safety** and **Security Checklist**. + +Forbidden for the org: shipping binaries whose permissions or data collection grew while Console Data safety answers and user-facing disclosure text stay unchanged - flag the mismatch in review comments before release. diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-code-coverage.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-code-coverage.md new file mode 100644 index 000000000..ec8a945de --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-code-coverage.md @@ -0,0 +1,157 @@ +# Code Coverage with JaCoCo + +Required: ship JaCoCo on `debug` with unit-test execution data on every PR. Combined unit + instrumented reports are the default convention path; treat that path as **Tier 2** below. + +## Coverage tiers + +| Tier | Scope | Use when | +|------------|--------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| +| **Tier 1** | JaCoCo plugin + `enableUnitTestCoverage` / `enableAndroidTestCoverage` on `debug`, unit tests (`testDebugUnitTest`), optional instrumented tests | AGP upgrade surfaces `MissingValueException` / "provider has no value" during `compile*JavaWithJavac`, or configure-time failures before tests run | +| **Tier 2** | Tier 1 plus `createDebugCombinedCoverageReport` from `assets/convention/config/Jacoco.kt` (`ScopedArtifacts` wiring for merged class dirs) | Green `./gradlew help` and stable `compile*JavaWithJavac` after the AGP bump | + +Escalate from Tier 1 to Tier 2 only after `./gradlew help` and `./gradlew testDebugUnitTest` succeed. + +## Setup + +### Apply Convention Plugins + +**For app module** (`app/build.gradle.kts`): +```kotlin +plugins { + alias(libs.plugins.app.android.application) + alias(libs.plugins.app.android.application.compose) + alias(libs.plugins.app.hilt) + alias(libs.plugins.app.android.application.jacoco) +} +``` + +**For library modules** (`:core:data`, `:feature:auth`, etc.): +```kotlin +plugins { + alias(libs.plugins.app.android.library) + alias(libs.plugins.app.hilt) + alias(libs.plugins.app.android.library.jacoco) +} +``` + +The JaCoCo convention plugins (from `assets/convention/`) automatically: +- Apply the JaCoCo plugin +- Configure JaCoCo version from version catalog +- Enable coverage for debug builds only +- Exclude generated code (Hilt, R files, BuildConfig) +- Register combined coverage report tasks (Tier 2 `ScopedArtifacts` path) + +## Generating Coverage Reports + +Run tests then the combined report task when Tier 2 is enabled. Instrumented tests require a connected device or emulator: + +```bash +./gradlew testDebugUnitTest connectedDebugAndroidTest +./gradlew createDebugCombinedCoverageReport +# library module variant: +./gradlew :core:data:createDebugCombinedCoverageReport +``` + +Output paths (per module under `build/reports/jacoco/createDebugCombinedCoverageReport/`): + +- `createDebugCombinedCoverageReport.xml` - feed to CI/Codecov. +- `html/index.html` - per-package, class, method drilldown. + +## Coverage Exclusions + +The following are automatically excluded from coverage: +- Android generated files (`R.class`, `BuildConfig.class`, `Manifest`) +- Hilt generated classes (`*_Hilt*.class`, `Hilt_*.class`, `*_Factory.class`) +- Dagger components (`*Component.class`, `*Module.class`) + +## CI Integration + +### GitHub Actions Example + +```yaml +name: Code Coverage + +on: + pull_request: + branches: [ main ] + push: + branches: [ main ] + +jobs: + coverage: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Run Unit Tests + run: ./gradlew testDebugUnitTest + + - name: Run Instrumented Tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 31 + target: google_apis + arch: x86_64 + script: ./gradlew connectedDebugAndroidTest + + - name: Generate Coverage Report + # Tier 2: requires working ScopedArtifacts + combined report task from JaCoCo convention + run: ./gradlew createDebugCombinedCoverageReport + + - name: Upload Coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: ./build/reports/jacoco/createDebugCombinedCoverageReport/createDebugCombinedCoverageReport.xml + flags: unittests + name: codecov-umbrella +``` + +### Enforcing Minimum Coverage + +```kotlin +// build.gradle.kts (project level or in a convention plugin) +tasks.withType().configureEach { + violationRules { + rule { + limit { minimum = "0.80".toBigDecimal() } + } + } +} +``` + +## Rules + +Required: +- Run `testDebugUnitTest` on every PR; gate coverage on `core/domain` and `core/data` using whichever tier is active. +- Target ≥ 80% line coverage on `core/domain` and `core/data` when Tier 2 reports are enabled. UI modules are measured but not gated. +- Keep instrumented tests under `src/androidTest/` and unit tests under `src/test/`. Coverage tasks read both paths when Tier 2 runs. +- Cover Compose UI through Compose UI tests and screenshot tests, not by gating composable line coverage. + +Forbidden: +- Adding tests solely to lift the coverage number (assertion-free `assertTrue(true)`, `runBlocking { fn() }` with no checks). +- Disabling exclusion patterns in `assets/convention/config/Jacoco.kt` to inflate coverage. + +## Troubleshooting + +| Symptom | Fix | +|-------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| No coverage data generated | Confirm tests run and pass; confirm `src/test/` vs `src/androidTest/` placement; coverage runs on `debug` only. | +| Classes missing from report | Check exclusion patterns in `assets/convention/config/Jacoco.kt`; confirm module applies the JaCoCo convention plugin. | +| Robolectric / JDK 11+ class loading | Convention plugin already sets `isIncludeNoLocationClasses = true` and `excludes = listOf("jdk.internal.*")`. If overriding, keep both. | +| `MissingValueException` / unresolved `Provider` during `compile*JavaWithJavac` after AGP or API bump | Drop to **Tier 1**: remove `app.android.application.jacoco` / `app.android.library.jacoco` from the failing module temporarily or fork `Jacoco.kt` without the `ScopedArtifacts.forScope(...).toGet(...)` block; rerun `./gradlew help --stacktrace`. Re-enable Tier 2 only after configuration is green. | +| Unknown configure failure | Run `./gradlew help --stacktrace` (and a build scan when CI allows) before bumping Kotlin or KSP; see [gradle-setup.md](/references/gradle-setup.md#agp-9-verification). | + +## References + +- [JaCoCo Documentation](https://www.jacoco.org/jacoco/trunk/doc/) +- [Android Testing: Code Coverage](https://developer.android.com/studio/test/code-coverage) +- Convention plugin implementations: `assets/convention/AndroidApplicationJacocoConventionPlugin.kt`, `assets/convention/AndroidLibraryJacocoConventionPlugin.kt`, `assets/convention/config/Jacoco.kt` diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-data-sync.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-data-sync.md new file mode 100644 index 000000000..a31765d04 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-data-sync.md @@ -0,0 +1,2344 @@ +# Android Data Synchronization & Offline-First + +Required: local Room 3 DB is the single source of truth, UI observes it, all writes are local-first + WorkManager-scheduled. + +Kotlin code must align with [kotlin-patterns.md](/references/kotlin-patterns.md). Repository / data-layer rules: [architecture.md](/references/architecture.md). Async + structured concurrency: [coroutines-patterns.md](/references/coroutines-patterns.md). Foreground sync services: [android-notifications.md](/references/android-notifications.md). + +## Table of Contents + +- [Offline-First Architecture](#offline-first-architecture) +- [Network State Monitoring](#network-state-monitoring) +- [Sync Strategies](#sync-strategies) +- [Conflict Resolution](#conflict-resolution) +- [Cache Invalidation](#cache-invalidation) +- [Retry Mechanisms](#retry-mechanisms) +- [WorkManager Integration](#workmanager-integration) + - [Work Constraints](#work-constraints) + - [Work Chaining](#work-chaining) + - [Passing Data Between Workers](#passing-data-between-workers) + - [Progress Updates](#progress-updates) + - [Testing WorkManager](#testing-workmanager) +- [Repository Pattern for Sync](#repository-pattern-for-sync) +- [Architecture Integration](#architecture-integration) +- [Testing](#testing) +- [Rules](#rules) + +## Offline-First Architecture + +Local database is the **single source of truth**. UI always reads from local database, never directly from network. + +### Core Principles + +1. **Local database is source of truth** - Room 3 database holds all data +2. **UI observes local data** - ViewModels collect Flow from Repository +3. **Background sync** - WorkManager syncs with remote when connected +4. **Optimistic updates** - Write to local first, sync to remote later +5. **Conflict resolution** - Handle server rejections and merge conflicts + +### Architecture Flow + +``` +┌──────────────┐ +│ UI Layer │ +└──────┬───────┘ + │ observes Flow + │ +┌──────▼────────────────────────────────────┐ +│ Repository │ +│ ┌────────────────────────────────────┐ │ +│ │ Local DB (Room) - Source of Truth│ │ +│ └────────────────────────────────────┘ │ +│ ▲ │ │ +│ │ │ │ +│ ┌─────────┴─────────▼───────┐ │ +│ │ Sync Coordinator │ │ +│ │ (WorkManager) │ │ +│ └─────────┬─────────▲───────┘ │ +│ │ │ │ +│ ┌────────────▼─────────┴──────────────┐ │ +│ │ Remote API │ │ +│ └─────────────────────────────────────┘ │ +└───────────────────────────────────────────┘ +``` + +### Repository with Offline-First + +```kotlin +// core/data/TaskRepository.kt +@Singleton +class TaskRepositoryImpl @Inject constructor( + private val taskDao: TaskDao, + private val taskApi: TaskApi, + private val networkMonitor: NetworkMonitor, + private val syncCoordinator: SyncCoordinator +) : TaskRepository { + + // UI observes this - always from local DB + override fun observeTasks(): Flow> = taskDao.observeAll() + .map { entities -> entities.map { it.toDomain() } } + + override fun observeTask(id: String): Flow = taskDao.observeById(id) + .map { it?.toDomain() } + + // Write to local first (optimistic update) + override suspend fun createTask(task: Task): Result = runCatching { + val entity = task.toEntity().copy( + syncStatus = SyncStatus.PENDING_CREATE, + lastModified = Clock.System.now() + ) + taskDao.insert(entity) + + // Schedule background sync + syncCoordinator.scheduleSyncNow() + + task + } + + override suspend fun updateTask(task: Task): Result = runCatching { + val entity = task.toEntity().copy( + syncStatus = SyncStatus.PENDING_UPDATE, + lastModified = Clock.System.now() + ) + taskDao.update(entity) + + syncCoordinator.scheduleSyncNow() + + task + } + + override suspend fun deleteTask(id: String): Result = runCatching { + taskDao.markAsDeleted(id, Clock.System.now()) + syncCoordinator.scheduleSyncNow() + } + + // Background sync - called by WorkManager + override suspend fun syncPendingChanges(): SyncResult { + if (!networkMonitor.isConnected()) { + return SyncResult.NoNetwork + } + + val pendingTasks = taskDao.getPendingSync() + val results = mutableListOf() + + for (task in pendingTasks) { + val result = when (task.syncStatus) { + SyncStatus.PENDING_CREATE -> syncCreate(task) + SyncStatus.PENDING_UPDATE -> syncUpdate(task) + SyncStatus.PENDING_DELETE -> syncDelete(task) + else -> continue + } + results.add(result) + } + + return if (results.all { it is SyncItemResult.Success }) { + SyncResult.Success(results.size) + } else { + val failures = results.filterIsInstance() + SyncResult.PartialSuccess( + successCount = results.size - failures.size, + failures = failures + ) + } + } + + private suspend fun syncCreate(task: TaskEntity): SyncItemResult = try { + val response = taskApi.createTask(task.toApiModel()) + taskDao.update( + task.copy( + serverId = response.id, + syncStatus = SyncStatus.SYNCED, + serverVersion = response.version + ) + ) + SyncItemResult.Success(task.id) + } catch (e: Exception) { + handleSyncError(task, e) + } + + private suspend fun syncUpdate(task: TaskEntity): SyncItemResult = try { + val response = taskApi.updateTask(task.serverId!!, task.toApiModel()) + + if (response.version <= task.serverVersion) { + // Server has newer version - conflict! + return resolveConflict(task, response) + } + + taskDao.update( + task.copy( + syncStatus = SyncStatus.SYNCED, + serverVersion = response.version + ) + ) + SyncItemResult.Success(task.id) + } catch (e: Exception) { + handleSyncError(task, e) + } + + private suspend fun syncDelete(task: TaskEntity): SyncItemResult = try { + taskApi.deleteTask(task.serverId!!) + taskDao.delete(task.id) + SyncItemResult.Success(task.id) + } catch (e: Exception) { + handleSyncError(task, e) + } + + private suspend fun handleSyncError( + task: TaskEntity, + error: Exception + ): SyncItemResult { + return when { + error is HttpException && error.code() == 409 -> { + SyncItemResult.Conflict(task.id, error.message()) + } + error is HttpException && error.code() in 400..499 -> { + // Client error - mark as failed, don't retry + taskDao.update(task.copy(syncStatus = SyncStatus.FAILED)) + SyncItemResult.Failed(task.id, error.message()) + } + else -> { + // Transient error - will retry later + SyncItemResult.Retry(task.id, error.message ?: "Unknown error") + } + } + } + + private suspend fun resolveConflict( + localTask: TaskEntity, + remoteTask: ApiTask + ): SyncItemResult { + // Server wins strategy (can be customized per app) + taskDao.update( + localTask.copy( + title = remoteTask.title, + description = remoteTask.description, + status = remoteTask.status, + serverVersion = remoteTask.version, + syncStatus = SyncStatus.SYNCED + ) + ) + return SyncItemResult.ConflictResolved(localTask.id, "Server version applied") + } +} + +enum class SyncStatus { + SYNCED, + PENDING_CREATE, + PENDING_UPDATE, + PENDING_DELETE, + FAILED +} + +sealed interface SyncResult { + data class Success(val itemsSynced: Int) : SyncResult + data class PartialSuccess( + val successCount: Int, + val failures: List + ) : SyncResult + data object NoNetwork : SyncResult + data class Error(val message: String) : SyncResult +} + +sealed interface SyncItemResult { + data class Success(val id: String) : SyncItemResult + data class Failed(val id: String, val reason: String) : SyncItemResult + data class Conflict(val id: String, val reason: String) : SyncItemResult + data class ConflictResolved(val id: String, val resolution: String) : SyncItemResult + data class Retry(val id: String, val reason: String) : SyncItemResult +} +``` + +### Room Entity with Sync Metadata + +Room 3 (`androidx.room3`): keep using **`suspend`** and **`Flow`** on DAOs; configure `Room.databaseBuilder` with **`setDriver(BundledSQLiteDriver())`** (see `references/android-security.md` and `references/testing.md`). + +```kotlin +// core/data/database/TaskEntity.kt +@Entity(tableName = "tasks") +data class TaskEntity( + @PrimaryKey + val id: String, + + @ColumnInfo(name = "server_id") + val serverId: String? = null, + + val title: String, + val description: String?, + val status: TaskStatus, + + @ColumnInfo(name = "sync_status") + val syncStatus: SyncStatus, + + @ColumnInfo(name = "last_modified") + val lastModified: Instant, + + @ColumnInfo(name = "server_version") + val serverVersion: Int = 0, + + @ColumnInfo(name = "is_deleted") + val isDeleted: Boolean = false +) + +@Dao +interface TaskDao { + @Query("SELECT * FROM tasks WHERE is_deleted = 0 ORDER BY last_modified DESC") + fun observeAll(): Flow> + + @Query("SELECT * FROM tasks WHERE id = :id AND is_deleted = 0") + fun observeById(id: String): Flow + + @Query("SELECT * FROM tasks WHERE sync_status != 'SYNCED' AND is_deleted = 0") + suspend fun getPendingSync(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(task: TaskEntity) + + @Update + suspend fun update(task: TaskEntity) + + @Query("UPDATE tasks SET is_deleted = 1, last_modified = :timestamp WHERE id = :id") + suspend fun markAsDeleted(id: String, timestamp: Instant) + + @Query("DELETE FROM tasks WHERE id = :id") + suspend fun delete(id: String) +} +``` + +## Database Optimization (Room/SQLite) + +### Room + +Room 3 disallows blocking DAO methods except reactive return types such as `Flow` ([Room 3 release notes](https://developer.android.com/jetpack/androidx/releases/room3)). + +Required: +- DAO methods are `suspend` or return `Flow` / `PagingSource`. Never return `List` from a non-suspend DAO method. +- Add `@Index` to every column used in a `WHERE`, `JOIN`, or `ORDER BY`. +- Wrap multi-statement writes in `@Transaction` (or use `@Insert` on `List` for batch insert). +- Cap result sets with `LIMIT … OFFSET …` or Paging 3 (`androidx.room3:room3-paging` + `@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)`); never `SELECT *` on tables that can grow beyond ~1k rows. + +```kotlin +@Dao +interface UserDao { + @Query("SELECT * FROM users") + suspend fun getAll(): List + + @Query("SELECT * FROM users") + fun observeAll(): Flow> + + @Insert + suspend fun insertAll(users: List) +} + +@Transaction +suspend fun updateUserAndPosts(user: User, posts: List) { + userDao.update(user) + postDao.insertAll(posts) +} + +@Entity( + tableName = "users", + indices = [ + Index(value = ["email"], unique = true), + Index(value = ["created_at"]) + ] +) +data class User( + @PrimaryKey val id: Long, + val email: String, + val name: String, + val createdAt: Long +) + +@Query("SELECT * FROM posts ORDER BY created_at DESC LIMIT :limit OFFSET :offset") +suspend fun getPostsPage(limit: Int, offset: Int): List + +@Query("SELECT * FROM posts ORDER BY created_at DESC") +fun getPostsPaged(): PagingSource +``` + +### SQLite + +Required: +- Store numbers as `INTEGER`, not `TEXT`. Schema columns must use the narrowest correct affinity. +- Run `EXPLAIN QUERY PLAN` for any new query touching > 1k rows; verify it uses indices. + +Room 3 does not expose `SupportSQLiteDatabase.query`. Ad-hoc SQL goes through [`SQLiteConnection`](https://developer.android.com/reference/kotlin/androidx/sqlite/SQLiteConnection) / driver APIs. + +## Network State Monitoring + +Monitor network connectivity to determine when to sync. + +### Network Monitor Interface + +```kotlin +// core/data/network/NetworkMonitor.kt +package com.example.core.data.network + +import kotlinx.coroutines.flow.Flow + +interface NetworkMonitor { + val isConnected: Flow + suspend fun isConnected(): Boolean +} +``` + +### Implementation with ConnectivityManager + +```kotlin +// core/data/network/ConnectivityNetworkMonitor.kt +@Singleton +class ConnectivityNetworkMonitor @Inject constructor( + @ApplicationContext private val context: Context +) : NetworkMonitor { + + private val connectivityManager = context.getSystemService() + + private val _isConnected = MutableStateFlow(checkConnection()) + override val isConnected: Flow = _isConnected.asStateFlow() + + private val networkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + _isConnected.value = true + } + + override fun onLost(network: Network) { + _isConnected.value = checkConnection() + } + + override fun onCapabilitiesChanged( + network: Network, + capabilities: NetworkCapabilities + ) { + val hasInternet = capabilities.hasCapability( + NetworkCapabilities.NET_CAPABILITY_INTERNET + ) + val isValidated = capabilities.hasCapability( + NetworkCapabilities.NET_CAPABILITY_VALIDATED + ) + _isConnected.value = hasInternet && isValidated + } + } + + init { + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + .build() + + connectivityManager?.registerNetworkCallback(request, networkCallback) + } + + override suspend fun isConnected(): Boolean { + return checkConnection() + } + + private fun checkConnection(): Boolean { + val network = connectivityManager?.activeNetwork ?: return false + val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false + + return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } +} +``` + +### Hilt Module + +```kotlin +// core/di/NetworkModule.kt +@Module +@InstallIn(SingletonComponent::class) +abstract class NetworkModule { + @Binds + abstract fun bindNetworkMonitor( + impl: ConnectivityNetworkMonitor + ): NetworkMonitor +} +``` + +### Manifest Permission + +```xml + +``` + +## Sync Strategies + +### 1. One-Way Sync (Server → Local) + +Pull latest data from server, overwrite local. + +```kotlin +override suspend fun syncFromServer(): SyncResult = runCatching { + val remoteTasks = taskApi.getTasks() + val entities = remoteTasks.map { apiTask -> + TaskEntity( + id = generateLocalId(), + serverId = apiTask.id, + title = apiTask.title, + description = apiTask.description, + status = apiTask.status, + syncStatus = SyncStatus.SYNCED, + lastModified = apiTask.updatedAt, + serverVersion = apiTask.version + ) + } + + // Clear and replace (or use upsert for incremental) + taskDao.deleteAll() + taskDao.insertAll(entities) + + SyncResult.Success(entities.size) +}.getOrElse { e -> + SyncResult.Error(e.message ?: "Sync failed") +} +``` + +### 2. Two-Way Sync (Bidirectional) + +Merge local and remote changes with conflict resolution. + +```kotlin +override suspend fun syncBidirectional(): SyncResult = coroutineScope { + // Step 1: Push local changes to server + val pushResult = syncPendingChanges() + + // Step 2: Pull remote changes + val pullResult = async { pullRemoteChanges() } + + // Step 3: Merge results + val pull = pullResult.await() + + when { + pushResult is SyncResult.Success && pull is SyncResult.Success -> { + SyncResult.Success(pushResult.itemsSynced + pull.itemsSynced) + } + else -> { + SyncResult.PartialSuccess( + successCount = (pushResult as? SyncResult.Success)?.itemsSynced ?: 0, + failures = emptyList() + ) + } + } +} + +private suspend fun pullRemoteChanges(): SyncResult = runCatching { + val lastSyncTime = preferencesDataSource.getLastSyncTime() + val remoteTasks = taskApi.getTasksSince(lastSyncTime) + + remoteTasks.forEach { apiTask -> + val localTask = taskDao.getByServerId(apiTask.id) + + when { + localTask == null -> { + // New remote task - insert + taskDao.insert(apiTask.toEntity()) + } + localTask.syncStatus == SyncStatus.SYNCED -> { + // Local is synced - apply remote changes + taskDao.update(localTask.copy( + title = apiTask.title, + description = apiTask.description, + status = apiTask.status, + serverVersion = apiTask.version, + lastModified = apiTask.updatedAt + )) + } + else -> { + // Conflict - local has pending changes + resolveConflict(localTask, apiTask) + } + } + } + + preferencesDataSource.setLastSyncTime(Clock.System.now()) + SyncResult.Success(remoteTasks.size) +}.getOrElse { e -> + SyncResult.Error(e.message ?: "Pull failed") +} +``` + +### 3. Periodic Sync + +Use WorkManager for periodic background sync. + +```kotlin +// core/sync/SyncWorker.kt +@HiltWorker +class SyncWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted params: WorkerParameters, + private val taskRepository: TaskRepository, + private val networkMonitor: NetworkMonitor +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + if (!networkMonitor.isConnected()) { + return Result.retry() + } + + return when (val syncResult = taskRepository.syncBidirectional()) { + is SyncResult.Success -> Result.success() + is SyncResult.PartialSuccess -> { + if (syncResult.successCount > 0) { + Result.success() + } else { + Result.retry() + } + } + is SyncResult.NoNetwork -> Result.retry() + is SyncResult.Error -> Result.failure() + } + } +} +``` + +### 4. Event-Driven Sync (Real-Time) + +Use WebSockets or Server-Sent Events for real-time updates. + +```kotlin +// core/data/realtime/TaskRealtimeSync.kt +@Singleton +class TaskRealtimeSync @Inject constructor( + private val webSocketClient: WebSocketClient, + private val taskDao: TaskDao +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + fun startListening() { + scope.launch { + webSocketClient.events + .catch { e -> /* Handle connection errors */ } + .collect { event -> + when (event) { + is TaskEvent.Created -> handleCreated(event.task) + is TaskEvent.Updated -> handleUpdated(event.task) + is TaskEvent.Deleted -> handleDeleted(event.taskId) + } + } + } + } + + private suspend fun handleCreated(task: ApiTask) { + val existing = taskDao.getByServerId(task.id) + if (existing == null) { + taskDao.insert(task.toEntity()) + } + } + + private suspend fun handleUpdated(task: ApiTask) { + val existing = taskDao.getByServerId(task.id) ?: return + + // Only apply if server version is newer + if (task.version > existing.serverVersion) { + taskDao.update(existing.copy( + title = task.title, + description = task.description, + status = task.status, + serverVersion = task.version, + lastModified = task.updatedAt + )) + } + } + + private suspend fun handleDeleted(taskId: String) { + taskDao.deleteByServerId(taskId) + } + + fun stopListening() { + scope.cancel() + } +} +``` + +## Conflict Resolution + +Handle cases where local and remote data diverge. + +### Conflict Resolution Strategies + +#### 1. Server Wins (Default) + +```kotlin +private suspend fun resolveConflict( + local: TaskEntity, + remote: ApiTask +): SyncItemResult { + // Discard local changes, apply server version + taskDao.update(local.copy( + title = remote.title, + description = remote.description, + status = remote.status, + serverVersion = remote.version, + syncStatus = SyncStatus.SYNCED, + lastModified = remote.updatedAt + )) + + return SyncItemResult.ConflictResolved( + local.id, + "Server version applied" + ) +} +``` + +#### 2. Client Wins + +```kotlin +private suspend fun resolveConflict( + local: TaskEntity, + remote: ApiTask +): SyncItemResult { + // Force push local changes to server + return try { + val updated = taskApi.forceUpdateTask( + remote.id, + local.toApiModel(), + expectedVersion = remote.version + ) + + taskDao.update(local.copy( + serverVersion = updated.version, + syncStatus = SyncStatus.SYNCED + )) + + SyncItemResult.ConflictResolved( + local.id, + "Client version applied" + ) + } catch (e: Exception) { + SyncItemResult.Conflict(local.id, e.message ?: "Conflict") + } +} +``` + +#### 3. Last Write Wins (Timestamp-Based) + +```kotlin +private suspend fun resolveConflict( + local: TaskEntity, + remote: ApiTask +): SyncItemResult { + val useLocal = local.lastModified > remote.updatedAt + + return if (useLocal) { + // Push local to server + try { + val updated = taskApi.updateTask(remote.id, local.toApiModel()) + taskDao.update(local.copy( + serverVersion = updated.version, + syncStatus = SyncStatus.SYNCED + )) + SyncItemResult.ConflictResolved(local.id, "Local (newer) applied") + } catch (e: Exception) { + SyncItemResult.Conflict(local.id, e.message ?: "Failed to push") + } + } else { + // Apply remote to local + taskDao.update(local.copy( + title = remote.title, + description = remote.description, + status = remote.status, + serverVersion = remote.version, + syncStatus = SyncStatus.SYNCED, + lastModified = remote.updatedAt + )) + SyncItemResult.ConflictResolved(local.id, "Remote (newer) applied") + } +} +``` + +#### 4. Manual Resolution (UI-Driven) + +```kotlin +private suspend fun resolveConflict( + local: TaskEntity, + remote: ApiTask +): SyncItemResult { + // Store conflict for user to resolve + conflictDao.insert( + ConflictEntity( + id = generateId(), + entityId = local.id, + localVersion = local.toJson(), + remoteVersion = remote.toJson(), + createdAt = Clock.System.now() + ) + ) + + // Mark entity as conflicted + taskDao.update(local.copy( + syncStatus = SyncStatus.CONFLICT + )) + + return SyncItemResult.Conflict( + local.id, + "Manual resolution required" + ) +} + +// UI to resolve conflicts +@Composable +fun ConflictResolutionScreen( + conflict: Conflict, + onResolve: (Resolution) -> Unit +) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Conflict detected for: ${conflict.entityName}") + + Spacer(modifier = Modifier.height(16.dp)) + + ConflictVersionCard( + title = "Your changes", + data = conflict.localVersion + ) + + ConflictVersionCard( + title = "Server version", + data = conflict.remoteVersion + ) + + Row(modifier = Modifier.fillMaxWidth()) { + Button( + onClick = { onResolve(Resolution.UseLocal) }, + modifier = Modifier.weight(1f) + ) { + Text("Keep Mine") + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { onResolve(Resolution.UseRemote) }, + modifier = Modifier.weight(1f) + ) { + Text("Use Server") + } + } + } +} +``` + +## Cache Invalidation + +Strategies to keep local cache fresh and consistent. + +### Time-Based Invalidation + +```kotlin +// core/data/cache/CacheManager.kt +@Singleton +class CacheManager @Inject constructor( + private val preferencesDataSource: PreferencesDataSource +) { + suspend fun isCacheValid( + key: String, + maxAge: Duration = 5.minutes + ): Boolean { + val lastUpdate = preferencesDataSource.getCacheTimestamp(key) ?: return false + val age = Clock.System.now() - lastUpdate + return age < maxAge + } + + suspend fun markCacheUpdated(key: String) { + preferencesDataSource.setCacheTimestamp(key, Clock.System.now()) + } + + suspend fun invalidateCache(key: String) { + preferencesDataSource.removeCacheTimestamp(key) + } +} + +// Usage in Repository +override fun observeTasks(): Flow> = flow { + // Emit cached data immediately + emitAll(taskDao.observeAll().map { it.map(TaskEntity::toDomain) }) +}.onStart { + // Refresh if cache is stale + if (!cacheManager.isCacheValid("tasks")) { + refreshTasks() + } +} + +private suspend fun refreshTasks() { + if (!networkMonitor.isConnected()) return + + try { + val remoteTasks = taskApi.getTasks() + taskDao.upsertAll(remoteTasks.map { it.toEntity() }) + cacheManager.markCacheUpdated("tasks") + } catch (e: Exception) { + // Log error but don't throw - UI still shows cached data + } +} +``` + +### Event-Based Invalidation + +```kotlin +// Invalidate when specific events occur +override suspend fun createTask(task: Task): Result = runCatching { + taskDao.insert(task.toEntity()) + + // Invalidate list cache since we added an item + cacheManager.invalidateCache("tasks") + + syncCoordinator.scheduleSyncNow() + task +} + +override suspend fun deleteTask(id: String): Result = runCatching { + taskDao.markAsDeleted(id, Clock.System.now()) + + // Invalidate both list and detail caches + cacheManager.invalidateCache("tasks") + cacheManager.invalidateCache("task_$id") + + syncCoordinator.scheduleSyncNow() +} +``` + +### Size-Based Invalidation (LRU Cache) + +```kotlin +// For in-memory caches (images, etc.) +@Singleton +class ImageCacheManager @Inject constructor() { + private val lruCache = object : LruCache( + maxSize = (Runtime.getRuntime().maxMemory() / 8).toInt() + ) { + override fun sizeOf(key: String, value: Bitmap): Int { + return value.byteCount + } + } + + fun get(key: String): Bitmap? = lruCache.get(key) + + fun put(key: String, bitmap: Bitmap) { + lruCache.put(key, bitmap) + } + + fun evict(key: String) { + lruCache.remove(key) + } + + fun clear() { + lruCache.evictAll() + } +} +``` + +## Retry Mechanisms + +Implement exponential backoff for transient failures. + +### Retry with Exponential Backoff + +```kotlin +// core/data/network/RetryStrategy.kt +interface RetryStrategy { + suspend fun retry(block: suspend () -> T): T +} + +@Singleton +class ExponentialBackoffRetry @Inject constructor() : RetryStrategy { + override suspend fun retry(block: suspend () -> T): T { + var currentDelay = 1.seconds + val maxDelay = 32.seconds + var attempts = 0 + val maxAttempts = 5 + + while (true) { + attempts++ + + try { + return block() + } catch (e: Exception) { + if (attempts >= maxAttempts) { + throw e + } + + // Don't retry on client errors (4xx) + if (e is HttpException && e.code() in 400..499) { + throw e + } + + delay(currentDelay) + currentDelay = (currentDelay * 2).coerceAtMost(maxDelay) + } + } + } +} + +// Usage in Repository +override suspend fun createTask(task: Task): Result = runCatching { + // Save locally first (optimistic) + val entity = task.toEntity().copy(syncStatus = SyncStatus.PENDING_CREATE) + taskDao.insert(entity) + + // Try to sync immediately with retry + if (networkMonitor.isConnected()) { + retryStrategy.retry { + val response = taskApi.createTask(task.toApiModel()) + taskDao.update(entity.copy( + serverId = response.id, + syncStatus = SyncStatus.SYNCED, + serverVersion = response.version + )) + } + } + + task +}.recoverCatching { e -> + // If immediate sync fails, WorkManager will retry later + task +} +``` + +### Configurable Retry Policy + +```kotlin +data class RetryPolicy( + val maxAttempts: Int = 5, + val initialDelay: Duration = 1.seconds, + val maxDelay: Duration = 32.seconds, + val backoffMultiplier: Double = 2.0, + val retryableErrors: Set = setOf(408, 429, 500, 502, 503, 504) +) + +class ConfigurableRetry @Inject constructor( + private val policy: RetryPolicy +) : RetryStrategy { + override suspend fun retry(block: suspend () -> T): T { + var currentDelay = policy.initialDelay + var attempts = 0 + + while (true) { + attempts++ + + try { + return block() + } catch (e: Exception) { + if (attempts >= policy.maxAttempts) { + throw e + } + + if (e is HttpException && e.code() !in policy.retryableErrors) { + throw e + } + + delay(currentDelay) + currentDelay = (currentDelay * policy.backoffMultiplier) + .coerceAtMost(policy.maxDelay) + } + } + } +} +``` + +### Retry with Jitter + +Prevent thundering herd problem by adding randomness: + +```kotlin +@Singleton +class JitteredRetry @Inject constructor() : RetryStrategy { + private val random = Random.Default + + override suspend fun retry(block: suspend () -> T): T { + var baseDelay = 1.seconds + val maxDelay = 32.seconds + var attempts = 0 + val maxAttempts = 5 + + while (true) { + attempts++ + + try { + return block() + } catch (e: Exception) { + if (attempts >= maxAttempts) throw e + if (e is HttpException && e.code() in 400..499) throw e + + // Add jitter: randomize between 0 and baseDelay + val jitter = random.nextDouble(0.0, baseDelay.inWholeMilliseconds.toDouble()) + delay(jitter.toLong().milliseconds) + + baseDelay = (baseDelay * 2).coerceAtMost(maxDelay) + } + } + } +} +``` + +## WorkManager Integration + +Schedule background sync with WorkManager for reliable execution. + +### Sync Coordinator + +```kotlin +// core/sync/SyncCoordinator.kt +interface SyncCoordinator { + fun scheduleSyncNow() + fun schedulePeriodicSync() + fun cancelSync() +} + +@Singleton +class WorkManagerSyncCoordinator @Inject constructor( + @ApplicationContext private val context: Context +) : SyncCoordinator { + + private val workManager = WorkManager.getInstance(context) + + override fun scheduleSyncNow() { + val syncRequest = OneTimeWorkRequestBuilder() + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + WorkRequest.MIN_BACKOFF_MILLIS, + TimeUnit.MILLISECONDS + ) + .build() + + workManager.enqueueUniqueWork( + "sync_now", + ExistingWorkPolicy.REPLACE, + syncRequest + ) + } + + override fun schedulePeriodicSync() { + val syncRequest = PeriodicWorkRequestBuilder( + repeatInterval = 1, + repeatIntervalTimeUnit = TimeUnit.HOURS, + flexTimeInterval = 15, + flexTimeIntervalUnit = TimeUnit.MINUTES + ) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .setRequiresBatteryNotLow(true) + .build() + ) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + WorkRequest.MIN_BACKOFF_MILLIS, + TimeUnit.MILLISECONDS + ) + .build() + + workManager.enqueueUniquePeriodicWork( + "periodic_sync", + ExistingPeriodicWorkPolicy.KEEP, + syncRequest + ) + } + + override fun cancelSync() { + workManager.cancelUniqueWork("sync_now") + workManager.cancelUniqueWork("periodic_sync") + } + + fun observeSyncStatus(): Flow { + return workManager.getWorkInfosForUniqueWorkFlow("sync_now") + .map { workInfos -> workInfos.firstOrNull() } + } +} +``` + +### Initialize Periodic Sync in Application + +```kotlin +@HiltAndroidApp +class MyApplication : Application() { + @Inject lateinit var syncCoordinator: SyncCoordinator + + override fun onCreate() { + super.onCreate() + + // Schedule periodic background sync + syncCoordinator.schedulePeriodicSync() + } +} +``` + +### Work Constraints + +WorkManager supports various constraints to control when work executes: + +```kotlin +fun scheduleSmartSync() { + val constraints = Constraints.Builder() + // Network constraints + .setRequiredNetworkType(NetworkType.CONNECTED) // Any network + // .setRequiredNetworkType(NetworkType.UNMETERED) // WiFi only + // .setRequiredNetworkType(NetworkType.NOT_ROAMING) // No roaming + + // Battery constraints + .setRequiresBatteryNotLow(true) // Battery above critical level + // .setRequiresCharging(true) // Device charging (API 23+) + + // Storage constraint + .setRequiresStorageNotLow(true) // Sufficient storage space + + // Device state (API 23+) + // .setRequiresDeviceIdle(true) // Device idle (for heavy operations) + + .build() + + val syncRequest = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .build() + + workManager.enqueue(syncRequest) +} +``` + +### Work Chaining + +Chain multiple work requests for sequential or parallel execution: + +#### Sequential Work Chain + +```kotlin +fun scheduleFullDataSync() { + // Step 1: Clean up old data + val cleanupWork = OneTimeWorkRequestBuilder() + .build() + + // Step 2: Download new data + val downloadWork = OneTimeWorkRequestBuilder() + .build() + + // Step 3: Process downloaded data + val processWork = OneTimeWorkRequestBuilder() + .build() + + // Chain: cleanup → download → process + workManager + .beginUniqueWork( + "full_sync", + ExistingWorkPolicy.REPLACE, + cleanupWork + ) + .then(downloadWork) + .then(processWork) + .enqueue() +} +``` + +#### Parallel Work with Join + +```kotlin +fun syncAllDataTypes() { + // Sync different data types in parallel + val syncTasks = OneTimeWorkRequestBuilder().build() + val syncProjects = OneTimeWorkRequestBuilder().build() + val syncUsers = OneTimeWorkRequestBuilder().build() + + // Final work after all complete + val notifyComplete = OneTimeWorkRequestBuilder().build() + + // Run tasks, projects, users in parallel, then notify + workManager + .beginWith(listOf(syncTasks, syncProjects, syncUsers)) + .then(notifyComplete) + .enqueue() +} +``` + +#### Complex Chain with Fan-Out/Fan-In + +```kotlin +fun syncWithBackupAndCleanup() { + // Initial work + val prepareWork = OneTimeWorkRequestBuilder().build() + + // Parallel operations after prepare + val syncWork = OneTimeWorkRequestBuilder().build() + val backupWork = OneTimeWorkRequestBuilder().build() + + // Final cleanup after both complete + val cleanupWork = OneTimeWorkRequestBuilder().build() + + workManager + .beginWith(prepareWork) + .then(listOf(syncWork, backupWork)) // Fan out + .then(cleanupWork) // Fan in + .enqueue() +} +``` + +### Passing Data Between Workers + +Use `Data` to pass information between chained workers: + +```kotlin +// First worker outputs data +@HiltWorker +class DownloadWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted params: WorkerParameters, + private val dataApi: DataApi +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + return try { + val data = dataApi.downloadData() + + // Output data for next worker + val outputData = Data.Builder() + .putInt("downloaded_count", data.size) + .putString("download_timestamp", Clock.System.now().toString()) + .putStringArray("item_ids", data.map { it.id }.toTypedArray()) + .build() + + Result.success(outputData) + } catch (e: Exception) { + Result.retry() + } + } +} + +// Second worker reads input data +@HiltWorker +class ProcessWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted params: WorkerParameters, + private val processor: DataProcessor +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + // Read data from previous worker + val itemCount = inputData.getInt("downloaded_count", 0) + val timestamp = inputData.getString("download_timestamp") + val itemIds = inputData.getStringArray("item_ids") + + if (itemIds == null || itemIds.isEmpty()) { + return Result.failure() + } + + return try { + processor.processItems(itemIds.toList()) + Result.success() + } catch (e: Exception) { + Result.retry() + } + } +} +``` + +### Progress Updates + +Report progress for long-running operations: + +```kotlin +@HiltWorker +class LargeDataSyncWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted params: WorkerParameters, + private val taskRepository: TaskRepository +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + val tasks = taskRepository.getPendingSync() + val totalTasks = tasks.size + + if (totalTasks == 0) { + return Result.success() + } + + // Use setForeground for expedited work (API 31+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + setForeground(createForegroundInfo()) + } + + tasks.forEachIndexed { index, task -> + try { + taskRepository.syncTask(task) + + // Update progress + val progress = ((index + 1) * 100) / totalTasks + setProgress( + Data.Builder() + .putInt("progress", progress) + .putInt("synced", index + 1) + .putInt("total", totalTasks) + .build() + ) + } catch (e: Exception) { + // Continue with next task + } + } + + return Result.success() + } + + private fun createForegroundInfo(): ForegroundInfo { + val notification = NotificationCompat.Builder( + applicationContext, + NotificationChannels.CHANNEL_FOREGROUND_SERVICE + ) + .setSmallIcon(R.drawable.ic_sync) + .setContentTitle("Syncing data") + .setOngoing(true) + .build() + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } else { + ForegroundInfo(NOTIFICATION_ID, notification) + } + } + + companion object { + private const val NOTIFICATION_ID = 2001 + } +} + +// Observe progress in ViewModel +val syncProgress: StateFlow = workManager + .getWorkInfoByIdFlow(workId) + .map { workInfo -> + val progress = workInfo?.progress?.getInt("progress", 0) ?: 0 + val synced = workInfo?.progress?.getInt("synced", 0) ?: 0 + val total = workInfo?.progress?.getInt("total", 0) ?: 0 + + SyncProgress( + percentage = progress, + itemsSynced = synced, + totalItems = total + ) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = SyncProgress(0, 0, 0) + ) +``` + +### Observing Work Status + +Monitor work state and handle completion: + +```kotlin +// Observe single work request +fun observeWorkStatus(workId: UUID): Flow { + return workManager.getWorkInfoByIdFlow(workId) +} + +// Observe unique work by name +fun observeSyncWork(): Flow { + return workManager.getWorkInfosForUniqueWorkFlow("sync_now") + .map { workInfos -> workInfos.firstOrNull() } +} + +// Observe work by tag +fun observeTaggedWork(tag: String): Flow> { + return workManager.getWorkInfosByTagFlow(tag) +} + +// Use in ViewModel +val syncState: StateFlow = syncCoordinator.observeSyncStatus() + .map { workInfo -> + when (workInfo?.state) { + WorkInfo.State.ENQUEUED -> SyncState.Pending + WorkInfo.State.RUNNING -> SyncState.Syncing + WorkInfo.State.SUCCEEDED -> SyncState.Success + WorkInfo.State.FAILED -> { + val error = workInfo.outputData.getString("error") ?: "Unknown error" + SyncState.Error(error) + } + WorkInfo.State.BLOCKED -> SyncState.Blocked + WorkInfo.State.CANCELLED -> SyncState.Cancelled + else -> SyncState.Idle + } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = SyncState.Idle + ) +``` + +### Expedited Work (API 31+) + +For time-sensitive operations that need to run immediately: + +```kotlin +fun scheduleExpeditedSync() { + val syncRequest = OneTimeWorkRequestBuilder() + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .build() + + workManager.enqueue(syncRequest) +} +``` + +### Testing WorkManager + +#### Testing with TestDriver + +```kotlin +// core/sync/SyncWorkerTest.kt +@RunWith(AndroidJUnit4::class) +class SyncWorkerTest { + private lateinit var context: Context + private lateinit var workManager: WorkManager + private lateinit var testDriver: TestDriver + + @Before + fun setup() { + context = ApplicationProvider.getApplicationContext() + + // Initialize WorkManager for tests + val config = Configuration.Builder() + .setMinimumLoggingLevel(Log.DEBUG) + .setExecutor(SynchronousExecutor()) + .build() + + WorkManagerTestInitHelper.initializeTestWorkManager(context, config) + + workManager = WorkManager.getInstance(context) + testDriver = WorkManagerTestInitHelper.getTestDriver(context)!! + } + + @Test + fun syncWorker_successfulSync_returnsSuccess() = runTest { + // Setup + val fakeRepository = FakeTaskRepository() + fakeRepository.setTasks(listOf( + Task("1", "Task 1", null, TaskStatus.TODO) + )) + + // Create work request + val request = OneTimeWorkRequestBuilder() + .build() + + // Enqueue work + workManager.enqueue(request).result.get() + + // Simulate constraints met + testDriver.setAllConstraintsMet(request.id) + + // Wait for work to complete + val workInfo = workManager.getWorkInfoById(request.id).get() + + // Assert + assertThat(workInfo.state).isEqualTo(WorkInfo.State.SUCCEEDED) + } + + @Test + fun syncWorker_noNetwork_retriesWork() = runTest { + val fakeNetworkMonitor = FakeNetworkMonitor() + fakeNetworkMonitor.setConnected(false) + + val request = OneTimeWorkRequestBuilder() + .build() + + workManager.enqueue(request).result.get() + testDriver.setAllConstraintsMet(request.id) + + val workInfo = workManager.getWorkInfoById(request.id).get() + + assertThat(workInfo.state).isEqualTo(WorkInfo.State.ENQUEUED) + assertThat(workInfo.runAttemptCount).isGreaterThan(0) + } + + @Test + fun syncWorker_updatesProgress() = runTest { + val request = OneTimeWorkRequestBuilder() + .build() + + workManager.enqueue(request).result.get() + testDriver.setAllConstraintsMet(request.id) + + // Collect progress updates + val progressValues = mutableListOf() + + workManager.getWorkInfoByIdFlow(request.id) + .take(5) + .collect { workInfo -> + workInfo?.progress?.getInt("progress", -1)?.let { + if (it >= 0) progressValues.add(it) + } + } + + assertThat(progressValues).isNotEmpty() + assertThat(progressValues.last()).isEqualTo(100) + } +} +``` + +#### Testing Work Chains + +```kotlin +@Test +fun workChain_executesSequentially() = runTest { + val cleanup = OneTimeWorkRequestBuilder().build() + val download = OneTimeWorkRequestBuilder().build() + val process = OneTimeWorkRequestBuilder().build() + + // Enqueue chain + workManager + .beginWith(cleanup) + .then(download) + .then(process) + .enqueue() + .result + .get() + + // Drive all constraints + testDriver.setAllConstraintsMet(cleanup.id) + testDriver.setAllConstraintsMet(download.id) + testDriver.setAllConstraintsMet(process.id) + + // Verify execution order + val cleanupInfo = workManager.getWorkInfoById(cleanup.id).get() + val downloadInfo = workManager.getWorkInfoById(download.id).get() + val processInfo = workManager.getWorkInfoById(process.id).get() + + assertThat(cleanupInfo.state).isEqualTo(WorkInfo.State.SUCCEEDED) + assertThat(downloadInfo.state).isEqualTo(WorkInfo.State.SUCCEEDED) + assertThat(processInfo.state).isEqualTo(WorkInfo.State.SUCCEEDED) +} + +@Test +fun workChain_failureStopsChain() = runTest { + val fakeDownloader = FakeDownloader() + fakeDownloader.setShouldFail(true) + + val download = OneTimeWorkRequestBuilder().build() + val process = OneTimeWorkRequestBuilder().build() + + workManager + .beginWith(download) + .then(process) + .enqueue() + .result + .get() + + testDriver.setAllConstraintsMet(download.id) + + val downloadInfo = workManager.getWorkInfoById(download.id).get() + val processInfo = workManager.getWorkInfoById(process.id).get() + + // Download failed, so process should be cancelled + assertThat(downloadInfo.state).isEqualTo(WorkInfo.State.FAILED) + assertThat(processInfo.state).isEqualTo(WorkInfo.State.CANCELLED) +} +``` + +#### Fake SyncCoordinator + +```kotlin +// core/testing/FakeSyncCoordinator.kt +class FakeSyncCoordinator : SyncCoordinator { + private val _workInfoFlow = MutableStateFlow(null) + + var syncScheduled = false + private set + + var periodicSyncScheduled = false + private set + + var syncCancelled = false + private set + + override fun scheduleSyncNow() { + syncScheduled = true + _workInfoFlow.value = createWorkInfo(WorkInfo.State.ENQUEUED) + } + + override fun schedulePeriodicSync() { + periodicSyncScheduled = true + } + + override fun cancelSync() { + syncCancelled = true + _workInfoFlow.value = createWorkInfo(WorkInfo.State.CANCELLED) + } + + fun observeSyncStatus(): Flow = _workInfoFlow.asStateFlow() + + fun setWorkState(state: WorkInfo.State) { + _workInfoFlow.value = createWorkInfo(state) + } + + private fun createWorkInfo(state: WorkInfo.State): WorkInfo { + // Create a mock WorkInfo for testing + return mock().apply { + whenever(this.state).thenReturn(state) + } + } + + fun reset() { + syncScheduled = false + periodicSyncScheduled = false + syncCancelled = false + _workInfoFlow.value = null + } +} +``` + +### WorkManager Rules + +Required: +- Use `enqueueUniqueWork` / `enqueueUniquePeriodicWork` with stable names; pick `ExistingWorkPolicy` deliberately. +- Set `Constraints` (network type, battery, storage) for every background job. +- Configure `setBackoffCriteria(BackoffPolicy.EXPONENTIAL, ...)` on retry-eligible work. +- Use expedited work (API 31+) only for user-visible, time-sensitive operations and pass `OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST`. +- Report progress with `setProgress(Data)` for long-running sync; observe via `WorkManager.getWorkInfoByIdFlow` in the ViewModel. +- Pass data between chained workers via `Data.Builder()` (≤ 10 KB). +- Cancel periodic work (`cancelUniqueWork`) when the feature toggles off. +- Test with `WorkManagerTestInitHelper` + `TestDriver`, simulating constraints. + +Forbidden: +- WorkManager for immediate, in-process work - use coroutines / `viewModelScope`. +- Storing payloads larger than 10 KB in `Data` (use Room or files and pass IDs). +- Periodic work with `repeatInterval` < 15 minutes (system rejects it). +- Blocking the main thread inside a `Worker` - always use `CoroutineWorker`. +- Long unbounded chains; batch related steps inside one worker. +- Driving UI directly from a `Worker`; UI listens to `StateFlow` / `WorkInfo`. + +### Work Constraints Comparison + +| Constraint | Use Case | Example | +|------------|----------|---------| +| `NetworkType.CONNECTED` | Any data sync | General API calls | +| `NetworkType.UNMETERED` | Large downloads | Media sync, backups | +| `NetworkType.NOT_ROAMING` | Cost-sensitive ops | International users | +| `requiresBatteryNotLow` | Background sync | Periodic updates | +| `requiresCharging` | Heavy operations | Database cleanup, indexing | +| `requiresStorageNotLow` | Downloads | Media, cache management | +| `requiresDeviceIdle` | Very heavy ops | Full database rebuild | + +## Repository Pattern for Sync + +Complete repository example integrating all patterns. + +```kotlin +// core/domain/TaskRepository.kt +interface TaskRepository { + fun observeTasks(): Flow> + fun observeTask(id: String): Flow + suspend fun createTask(task: Task): Result + suspend fun updateTask(task: Task): Result + suspend fun deleteTask(id: String): Result + suspend fun syncBidirectional(): SyncResult + suspend fun refreshTasks(): Result +} + +// core/data/TaskRepositoryImpl.kt (complete implementation) +@Singleton +class TaskRepositoryImpl @Inject constructor( + private val taskDao: TaskDao, + private val taskApi: TaskApi, + private val networkMonitor: NetworkMonitor, + private val syncCoordinator: SyncCoordinator, + private val retryStrategy: RetryStrategy, + private val cacheManager: CacheManager +) : TaskRepository { + + override fun observeTasks(): Flow> = taskDao.observeAll() + .map { entities -> entities.map { it.toDomain() } } + .onStart { + if (!cacheManager.isCacheValid("tasks", maxAge = 5.minutes)) { + refreshTasks() + } + } + + override fun observeTask(id: String): Flow = taskDao.observeById(id) + .map { it?.toDomain() } + + override suspend fun createTask(task: Task): Result = runCatching { + val entity = task.toEntity().copy( + syncStatus = SyncStatus.PENDING_CREATE, + lastModified = Clock.System.now() + ) + taskDao.insert(entity) + cacheManager.invalidateCache("tasks") + syncCoordinator.scheduleSyncNow() + task + } + + override suspend fun updateTask(task: Task): Result = runCatching { + val entity = task.toEntity().copy( + syncStatus = SyncStatus.PENDING_UPDATE, + lastModified = Clock.System.now() + ) + taskDao.update(entity) + cacheManager.invalidateCache("tasks") + cacheManager.invalidateCache("task_${task.id}") + syncCoordinator.scheduleSyncNow() + task + } + + override suspend fun deleteTask(id: String): Result = runCatching { + taskDao.markAsDeleted(id, Clock.System.now()) + cacheManager.invalidateCache("tasks") + cacheManager.invalidateCache("task_$id") + syncCoordinator.scheduleSyncNow() + } + + override suspend fun syncBidirectional(): SyncResult { + if (!networkMonitor.isConnected()) { + return SyncResult.NoNetwork + } + + return coroutineScope { + val pushJob = async { pushLocalChanges() } + val pullJob = async { pullRemoteChanges() } + + val pushResult = pushJob.await() + val pullResult = pullJob.await() + + when { + pushResult is SyncResult.Success && pullResult is SyncResult.Success -> { + cacheManager.markCacheUpdated("tasks") + SyncResult.Success( + pushResult.itemsSynced + pullResult.itemsSynced + ) + } + else -> SyncResult.PartialSuccess( + successCount = 0, + failures = emptyList() + ) + } + } + } + + override suspend fun refreshTasks(): Result = runCatching { + if (!networkMonitor.isConnected()) { + return Result.success(Unit) + } + + retryStrategy.retry { + val remoteTasks = taskApi.getTasks() + val entities = remoteTasks.map { it.toEntity() } + taskDao.upsertAll(entities) + cacheManager.markCacheUpdated("tasks") + } + } + + private suspend fun pushLocalChanges(): SyncResult { + val pending = taskDao.getPendingSync() + val results = pending.map { task -> + when (task.syncStatus) { + SyncStatus.PENDING_CREATE -> syncCreate(task) + SyncStatus.PENDING_UPDATE -> syncUpdate(task) + SyncStatus.PENDING_DELETE -> syncDelete(task) + else -> SyncItemResult.Success(task.id) + } + } + + return if (results.all { it is SyncItemResult.Success }) { + SyncResult.Success(results.size) + } else { + SyncResult.PartialSuccess( + successCount = results.count { it is SyncItemResult.Success }, + failures = results.filterIsInstance() + ) + } + } + + private suspend fun pullRemoteChanges(): SyncResult = runCatching { + val lastSync = cacheManager.getLastSyncTime("tasks") + val remoteTasks = taskApi.getTasksSince(lastSync) + + remoteTasks.forEach { apiTask -> + val local = taskDao.getByServerId(apiTask.id) + + when { + local == null -> taskDao.insert(apiTask.toEntity()) + local.syncStatus == SyncStatus.SYNCED -> { + taskDao.update(local.copy( + title = apiTask.title, + description = apiTask.description, + status = apiTask.status, + serverVersion = apiTask.version, + lastModified = apiTask.updatedAt + )) + } + else -> resolveConflict(local, apiTask) + } + } + + SyncResult.Success(remoteTasks.size) + }.getOrElse { e -> + SyncResult.Error(e.message ?: "Pull failed") + } + + private suspend fun syncCreate(task: TaskEntity): SyncItemResult = try { + retryStrategy.retry { + val response = taskApi.createTask(task.toApiModel()) + taskDao.update(task.copy( + serverId = response.id, + syncStatus = SyncStatus.SYNCED, + serverVersion = response.version + )) + } + SyncItemResult.Success(task.id) + } catch (e: Exception) { + handleSyncError(task, e) + } + + private suspend fun syncUpdate(task: TaskEntity): SyncItemResult = try { + retryStrategy.retry { + val response = taskApi.updateTask(task.serverId!!, task.toApiModel()) + + if (response.version <= task.serverVersion) { + return@retry resolveConflict(task, response) + } + + taskDao.update(task.copy( + syncStatus = SyncStatus.SYNCED, + serverVersion = response.version + )) + } + SyncItemResult.Success(task.id) + } catch (e: Exception) { + handleSyncError(task, e) + } + + private suspend fun syncDelete(task: TaskEntity): SyncItemResult = try { + retryStrategy.retry { + taskApi.deleteTask(task.serverId!!) + taskDao.delete(task.id) + } + SyncItemResult.Success(task.id) + } catch (e: Exception) { + handleSyncError(task, e) + } + + private suspend fun handleSyncError( + task: TaskEntity, + error: Exception + ): SyncItemResult { + return when { + error is HttpException && error.code() == 409 -> { + SyncItemResult.Conflict(task.id, error.message()) + } + error is HttpException && error.code() in 400..499 -> { + taskDao.update(task.copy(syncStatus = SyncStatus.FAILED)) + SyncItemResult.Failed(task.id, error.message()) + } + else -> { + SyncItemResult.Retry(task.id, error.message ?: "Unknown error") + } + } + } + + private suspend fun resolveConflict( + local: TaskEntity, + remote: ApiTask + ): SyncItemResult { + // Server wins strategy + taskDao.update(local.copy( + title = remote.title, + description = remote.description, + status = remote.status, + serverVersion = remote.version, + syncStatus = SyncStatus.SYNCED, + lastModified = remote.updatedAt + )) + return SyncItemResult.ConflictResolved(local.id, "Server version applied") + } +} +``` + +## Architecture Integration + +### ViewModel Observing Sync State + +```kotlin +// feature/tasks/presentation/TasksViewModel.kt +@HiltViewModel +class TasksViewModel @Inject constructor( + private val taskRepository: TaskRepository, + private val syncCoordinator: SyncCoordinator +) : ViewModel() { + + val tasks: StateFlow> = taskRepository.observeTasks() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList() + ) + + val syncState: StateFlow = syncCoordinator.observeSyncStatus() + .map { workInfo -> + when (workInfo?.state) { + WorkInfo.State.RUNNING -> SyncState.Syncing + WorkInfo.State.SUCCEEDED -> SyncState.Success + WorkInfo.State.FAILED -> SyncState.Error("Sync failed") + else -> SyncState.Idle + } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = SyncState.Idle + ) + + fun syncNow() { + syncCoordinator.scheduleSyncNow() + } + + fun createTask(title: String) { + viewModelScope.launch { + val task = Task( + id = generateId(), + title = title, + description = null, + status = TaskStatus.TODO + ) + taskRepository.createTask(task) + } + } +} + +sealed interface SyncState { + data object Idle : SyncState + data object Syncing : SyncState + data object Success : SyncState + data class Error(val message: String) : SyncState +} +``` + +### UI Displaying Sync Status + +```kotlin +@Composable +fun TasksScreen( + viewModel: TasksViewModel = hiltViewModel() +) { + val tasks by viewModel.tasks.collectAsStateWithLifecycle() + val syncState by viewModel.syncState.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Tasks") }, + actions = { + SyncButton( + syncState = syncState, + onSyncClick = { viewModel.syncNow() } + ) + } + ) + } + ) { padding -> + Column(modifier = Modifier.padding(padding)) { + // Show sync indicator + when (syncState) { + is SyncState.Syncing -> LinearProgressIndicator( + modifier = Modifier.fillMaxWidth() + ) + is SyncState.Error -> { + Text( + text = (syncState as SyncState.Error).message, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(16.dp) + ) + } + else -> {} + } + + LazyColumn { + items(tasks, key = { it.id }) { task -> + TaskItem(task = task) + } + } + } + } +} + +@Composable +fun SyncButton( + syncState: SyncState, + onSyncClick: () -> Unit +) { + IconButton( + onClick = onSyncClick, + enabled = syncState !is SyncState.Syncing + ) { + when (syncState) { + is SyncState.Syncing -> { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + } + else -> { + Icon( + painter = painterResource(R.drawable.ic_sync), + contentDescription = "Sync" + ) + } + } + } +} +``` + +## Testing + +### Fake Repository + +```kotlin +// core/testing/FakeTaskRepository.kt +class FakeTaskRepository : TaskRepository { + private val _tasks = MutableStateFlow>(emptyList()) + private var shouldFailSync = false + private var syncDelay = Duration.ZERO + + override fun observeTasks(): Flow> = _tasks.asStateFlow() + + override fun observeTask(id: String): Flow = _tasks + .map { tasks -> tasks.find { it.id == id } } + + override suspend fun createTask(task: Task): Result = runCatching { + _tasks.value = _tasks.value + task + task + } + + override suspend fun updateTask(task: Task): Result = runCatching { + _tasks.value = _tasks.value.map { + if (it.id == task.id) task else it + } + task + } + + override suspend fun deleteTask(id: String): Result = runCatching { + _tasks.value = _tasks.value.filterNot { it.id == id } + } + + override suspend fun syncBidirectional(): SyncResult { + delay(syncDelay) + + return if (shouldFailSync) { + SyncResult.Error("Sync failed") + } else { + SyncResult.Success(_tasks.value.size) + } + } + + override suspend fun refreshTasks(): Result = runCatching { + // No-op for fake + } + + fun setShouldFailSync(shouldFail: Boolean) { + shouldFailSync = shouldFail + } + + fun setSyncDelay(delay: Duration) { + syncDelay = delay + } + + fun setTasks(tasks: List) { + _tasks.value = tasks + } +} +``` + +### Fake Network Monitor + +```kotlin +// core/testing/FakeNetworkMonitor.kt +class FakeNetworkMonitor : NetworkMonitor { + private val _isConnected = MutableStateFlow(true) + override val isConnected: Flow = _isConnected.asStateFlow() + + override suspend fun isConnected(): Boolean = _isConnected.value + + fun setConnected(connected: Boolean) { + _isConnected.value = connected + } +} +``` + +### Testing Sync Logic + +```kotlin +// core/data/TaskRepositoryTest.kt +@Test +fun `syncBidirectional returns NoNetwork when offline`() = runTest { + val fakeNetworkMonitor = FakeNetworkMonitor() + fakeNetworkMonitor.setConnected(false) + + val repository = TaskRepositoryImpl( + taskDao = fakeTaskDao, + taskApi = fakeTaskApi, + networkMonitor = fakeNetworkMonitor, + syncCoordinator = fakeSyncCoordinator, + retryStrategy = fakeRetryStrategy, + cacheManager = fakeCacheManager + ) + + val result = repository.syncBidirectional() + + assertThat(result).isEqualTo(SyncResult.NoNetwork) +} + +@Test +fun `createTask writes to local database and schedules sync`() = runTest { + val repository = TaskRepositoryImpl(/* ... */) + val task = Task(id = "1", title = "Test", description = null, status = TaskStatus.TODO) + + repository.createTask(task) + + val saved = fakeTaskDao.getById("1") + assertThat(saved).isNotNull() + assertThat(saved?.syncStatus).isEqualTo(SyncStatus.PENDING_CREATE) + verify(fakeSyncCoordinator).scheduleSyncNow() +} + +@Test +fun `conflict resolution applies server version when strategy is ServerWins`() = runTest { + // Setup local task with pending changes + val localTask = TaskEntity( + id = "1", + serverId = "server-1", + title = "Local Title", + description = null, + status = TaskStatus.TODO, + syncStatus = SyncStatus.PENDING_UPDATE, + lastModified = Clock.System.now(), + serverVersion = 1 + ) + fakeTaskDao.insert(localTask) + + // Server has newer version + val remoteTask = ApiTask( + id = "server-1", + title = "Server Title", + description = null, + status = TaskStatus.IN_PROGRESS, + version = 2, + updatedAt = Clock.System.now() + ) + fakeTaskApi.setMockResponse(remoteTask) + + val repository = TaskRepositoryImpl(/* ... */) + repository.syncBidirectional() + + val updated = fakeTaskDao.getById("1") + assertThat(updated?.title).isEqualTo("Server Title") + assertThat(updated?.status).isEqualTo(TaskStatus.IN_PROGRESS) + assertThat(updated?.syncStatus).isEqualTo(SyncStatus.SYNCED) +} +``` + +### Testing ViewModel with Sync + +```kotlin +// feature/tasks/presentation/TasksViewModelTest.kt +@Test +fun `syncNow triggers sync coordinator`() = runTest { + val fakeSyncCoordinator = FakeSyncCoordinator() + val viewModel = TasksViewModel( + taskRepository = fakeTaskRepository, + syncCoordinator = fakeSyncCoordinator + ) + + viewModel.syncNow() + + assertThat(fakeSyncCoordinator.syncScheduled).isTrue() +} + +@Test +fun `syncState reflects WorkInfo state`() = runTest { + val workInfoFlow = MutableStateFlow(null) + val fakeSyncCoordinator = FakeSyncCoordinator() + fakeSyncCoordinator.setWorkInfoFlow(workInfoFlow) + + val viewModel = TasksViewModel( + taskRepository = fakeTaskRepository, + syncCoordinator = fakeSyncCoordinator + ) + + // Initially idle + assertThat(viewModel.syncState.value).isEqualTo(SyncState.Idle) + + // Simulate running + workInfoFlow.value = WorkInfo(/* state = RUNNING */) + advanceUntilIdle() + assertThat(viewModel.syncState.value).isEqualTo(SyncState.Syncing) + + // Simulate success + workInfoFlow.value = WorkInfo(/* state = SUCCEEDED */) + advanceUntilIdle() + assertThat(viewModel.syncState.value).isEqualTo(SyncState.Success) +} +``` + +## Rules + +Required: +- Local DB is the only source UI observes; networking results land in the DB before reaching UI. +- Write to the DB first (optimistic), then enqueue sync via the `SyncCoordinator`. +- Store sync metadata on every syncable entity: `syncStatus`, `lastModified`, `serverVersion`. +- Check `NetworkMonitor` before any sync attempt; treat `NoNetwork` as a `Result.retry()`. +- Pick exactly one conflict-resolution strategy per entity type and document it on the repository. +- Use exponential backoff with bounded jitter for transient failures; cap at `maxAttempts`. +- Invalidate caches on every write that mutates the affected key(s). +- Treat partial sync failures as success-with-failures, not full failure; surface counts to the UI. +- Test offline behaviour with `FakeNetworkMonitor.setConnected(false)` and HTTP error injection. + +Forbidden: +- UI / ViewModels reading from `taskApi` directly. Network is repository-internal. +- Returning stale data without scheduling a refresh when cache is invalid. +- Dropping local edits because a sync failed. +- Unbounded retry loops. +- Silently swallowing conflicts. +- Auto-syncing on metered networks without explicit user opt-in. +- `SharedPreferences` / `DataStore` for relational or list data - that's Room's job. +- Leaking `CoroutineScope`s from repositories; cancel scopes in `@PreDestroy` / lifecycle teardown. + +### Sync Frequency Guidelines + +- **Critical data**: Sync immediately + periodic (15 min) +- **User-generated content**: Sync immediately on create/update/delete +- **Feed/timeline**: Periodic (30-60 min) + manual refresh +- **Profile data**: On app start + manual refresh +- **Settings**: Sync immediately on change + +### Conflict Resolution Strategy Selection + +| Scenario | Strategy | Reason | +|-----------------------|-------------------|--------------------------------| +| User preferences | Client wins | User's device is authoritative | +| Shared documents | Last write wins | Simple, works for most cases | +| Collaborative editing | Manual resolution | Preserve both versions | +| Server-managed data | Server wins | Server is authoritative | + +### Performance Considerations + +- **Batch operations**: Sync multiple items in one request +- **Delta sync**: Only sync changed items since last sync +- **Pagination**: Don't load entire dataset at once +- **Compression**: Compress large payloads +- **Background threads**: All sync operations on IO dispatcher + +## References + +- [Repository Pattern - Android Developers](https://developer.android.com/topic/architecture/data-layer) +- [Offline-First Architecture - Android Developers](https://developer.android.com/topic/architecture/data-layer/offline-first) +- [WorkManager - Android Developers](https://developer.android.com/topic/libraries/architecture/workmanager) +- [Save data in a local database with Room](https://developer.android.com/training/data-storage/room) +- [Room 3 releases](https://developer.android.com/jetpack/androidx/releases/room3) +- [ConnectivityManager](https://developer.android.com/reference/android/net/ConnectivityManager) +- [Network Callbacks](https://developer.android.com/training/monitoring-device-state/connectivity-status-type) +- [Exponential Backoff](https://cloud.google.com/iot/docs/how-tos/exponential-backoff) diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-debugging.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-debugging.md new file mode 100644 index 000000000..fc9e1cb8c --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-debugging.md @@ -0,0 +1,322 @@ +# Android Debugging + +## Table of Contents + +1. [Logcat](#logcat) +2. [ANR Debugging](#anr-debugging) +3. [Memory Leaks](#memory-leaks) +4. [R8 Stack Trace De-obfuscation](#r8-stack-trace-de-obfuscation) +5. [Gradle Build Failures](#gradle-build-failures) +6. [Compose Recomposition Debugging](#compose-recomposition-debugging) +7. [Multi-Layer Boundary Debugging](#multi-layer-boundary-debugging) +8. [ADB Quick Reference](#adb-quick-reference) + +## Logcat + +### Filtering by App + +```bash +# Stream logs filtered by app package +adb logcat --pid=$(adb shell pidof -s com.example.app) + +# Filter by tag and level +adb logcat -s "YourTag:E" + +# Save full logcat to file for analysis +adb logcat -d > crash_log.txt +``` + +Log levels: `V` (verbose), `D` (debug), `I` (info), `W` (warn), `E` (error), `F` (fatal). + +### When to use each level (operational discipline) + +Use `android.util.Log` consistently so production and debug logs stay readable: + +| Level | Use for | +|---------|---------------------------------------------------------------------------------------| +| `Log.i` | Normal checkpoints (feature started, important parameters that are not PII) | +| `Log.w` | Recoverable problems (fallback used, retry, unexpected but handled state) | +| `Log.e` | Failures (request failed, uncaught path in a catch that you log before mapping to UI) | + +Avoid `Log.v`/`Log.d` spam in hot paths in release builds. Never log secrets, tokens, or personal data (see `references/android-security.md`). + +### Reading Crash Logs + +The root cause is most often at the bottom of the `Caused by:` chain, not the top-level exception. +Always read the full stack trace before forming a hypothesis. + +## ANR Debugging + +ANRs occur when Android's system watchdog decides the main thread (or a bounded callback path) did not respond in time. The timeout depends on **what** was blocked: + +| Scenario | Typical timeout | +|-----------------------------------------------|----------------------------------------------------| +| Input dispatch (touch, key) on main | ~5 seconds | +| `BroadcastReceiver.onReceive` running on main | ~10 seconds | +| Starting a service on main / bound work | on the order of ~20 seconds for some service paths | + +The **~5 second** rule is the one you hit most often from heavy work on the main thread. + +### Gathering Evidence + +```bash +# Pull ANR trace from device +adb pull /data/anr/traces.txt ./anr_traces.txt + +# Stream while reproducing +adb logcat -s "ActivityManager:E" | grep -A 30 "ANR in" +``` + +### What to Look For + +In the trace file, find the `main` thread and check its state: + +- `MONITOR` - waiting for a lock held by another thread (deadlock candidate) +- `TIMED_WAITING` on `sleep` - something called `Thread.sleep()` on main +- Blocking I/O calls - database queries, network calls, file reads on main thread + +### Common Causes + +- Database or network call on the main thread +- `runBlocking` on the main thread +- Deadlock between coroutine scopes +- Expensive computation (JSON parsing, bitmap decoding) on main thread + +## Memory Leaks + +### Common Causes + +1. **Static References to Context**: Storing an `Activity` context statically prevents the entire activity (and its view hierarchy) from being garbage collected. If you must use a static context, use the Application context. +2. **Inner Classes Holding Activity References**: Non-static inner classes implicitly hold a reference to their outer `Activity`. If doing background work, use a static inner class with a `WeakReference`, or prefer Kotlin Coroutines tied to `lifecycleScope`. +3. **Handler Memory Leaks**: A `Handler` processing delayed messages can keep the `Activity` alive after it's destroyed. Always call `handler.removeCallbacksAndMessages(null)` in `onDestroy()`. + +### LeakCanary + +On Android Studio Panda 3+, use the Profiler "Analyze Leaks" task. No LeakCanary dependency required ([reference](https://developer.android.com/studio/preview/features#leakcanary)). + +On older Android Studio versions, add to `debugImplementation` only: + +```kotlin +debugImplementation(libs.leakcanary) +``` + +Reading a leak trace: the first bold entry is the leaking object; the path shows what holds the reference. Fix by clearing the reference in the matching lifecycle callback. + +### Manual Heap Dump + +```bash +adb shell am dumpheap com.example.app /data/local/tmp/heap.hprof +adb pull /data/local/tmp/heap.hprof ./heap.hprof +``` + +Open the `.hprof` file in Android Studio's Memory Profiler for analysis. + +## R8 Stack Trace De-obfuscation + +R8 (the default code shrinker/obfuscator in AGP) renames classes, methods, and fields in release +builds. Crash stack traces from production are obfuscated and unreadable without the mapping file. + +For R8 build configuration and keep rules, see [gradle-setup.md](/references/gradle-setup.md#r8--proguard-configuration). + +### R8 Output Files + +After a release build (`./gradlew assembleRelease`), R8 produces these files in +`app/build/outputs/mapping//`: + +| File | Purpose | +|---------------------|---------------------------------------------------------------| +| `mapping.txt` | Maps obfuscated names back to original names | +| `usage.txt` | Lists classes and members that were removed (tree-shaken) | +| `seeds.txt` | Lists classes and members matched by `-keep` rules (retained) | +| `configuration.txt` | The merged R8 configuration from all sources | + +**Always archive `mapping.txt` alongside every release build.** Without it, production crash +traces cannot be decoded. Crashlytics and Sentry Gradle plugins upload this automatically. + +### Using retrace (Automated) + +```bash +# AGP retrace task +./gradlew :app:retrace --stacktrace-file crash.txt + +# Or use the retrace CLI directly with the mapping file +retrace mapping.txt crash.txt + +# retrace is bundled with Android SDK command-line tools: +# $ANDROID_HOME/cmdline-tools/latest/bin/retrace +``` + +### Manual De-obfuscation + +When `retrace` is not available or you need to decode a partial trace manually, read `mapping.txt` +directly. + +#### Mapping File Format + +Each line maps an original name to its obfuscated name: + +``` +com.example.app.data.UserRepository -> a.b.c: + java.lang.String userId -> a + void fetchUser(java.lang.String) -> b + 1:3:void fetchUser(java.lang.String):42:44 -> b +``` + +Format: +- `original.ClassName -> obfuscated.Name:` - class mapping +- ` originalType fieldName -> obfuscatedName` - field mapping (indented) +- ` returnType methodName(params) -> obfuscatedName` - method mapping (indented) +- ` startLine:endLine:returnType methodName(params):originalStart:originalEnd -> obfuscatedName` - line number mapping + +#### Manual decode (when retrace is unavailable) + +For each obfuscated frame `obfuscated.Class.method(SourceFile:N)`: + +1. Find `-> obfuscated.Class:` in `mapping.txt` → class name on the left. +2. Under that class block, find `-> method` → method signature on the left. +3. Compute the original line from the line-range entry `start:end:signature:origStart:origEnd -> method`: `origLine = origStart + (N - start)`. + +Worked example. Frame `a.b.c.b(SourceFile:2)` against mapping: + +``` +com.example.app.data.UserRepository -> a.b.c: + 1:3:com.example.app.domain.User fetchUser(java.lang.String):42:44 -> b +``` + +Resolves to `com.example.app.data.UserRepository.fetchUser(UserRepository.kt:43)` (`42 + (2 - 1) = 43`). + +### Debugging Unexpected Removal + +If a class or method is unexpectedly removed or renamed by R8: + +```bash +# Check what was removed +grep "ClassName" app/build/outputs/mapping/release/usage.txt + +# Check what was kept +grep "ClassName" app/build/outputs/mapping/release/seeds.txt +``` + +If the class appears in `usage.txt`, add a `-keep` rule in `proguard-rules.pro`. If it appears in +neither file, it was likely not included in any dependency. + +## Gradle Build Failures + +Read Gradle errors from the **bottom up** - Gradle wraps errors in multiple layers. + +### Common Error Patterns + +| Error | Investigation | +|---------------------------|---------------------------------------------------------------------------------------------------| +| `Manifest merger failed` | Check `app/build/intermediates/merged_manifests/` for conflicts | +| `Duplicate class` | Run `./gradlew :app:dependencies` and look for the same class in multiple transitive deps | +| `Could not resolve` | Check repository declarations, VPN/proxy, verify the dependency version exists | +| `Unresolved reference` | Missing import, wrong module dependency, or typo; ensure the declaring module is on the classpath | +| `Type mismatch` | Wrong generic, nullable vs non-null, or API change after a dependency bump | +| `@Composable invocations` | Composable called from a non-`@Composable` context; lift the call or wrap in a composable | +| `AAPT` / resource errors | Invalid XML, bad `@drawable` reference, or merge conflict in `res/` | +| `D8/R8: Type not present` | Missing `-keep` rule or desugaring issue; check `minSdk` vs API used | +| `KSP error` | Look for the processor's own error message above the Gradle wrapper | + +### Dependency Investigation + +```bash +# Full dependency tree for a configuration +./gradlew :app:dependencies --configuration releaseRuntimeClasspath + +# Run with stacktrace for deeper errors +./gradlew assembleDebug --stacktrace --info +``` + +## Compose Recomposition Debugging + +### Identifying Excessive Recomposition + +1. **Layout Inspector** (Android Studio) - enable "Show recomposition counts" to find hot paths +2. Temporary `SideEffect` logging: + +```kotlin +@Composable +fun MyScreen(state: UiState) { + SideEffect { Log.d("Recompose", "MyScreen recomposed") } + // ... +} +``` + +### Common Causes + +- `State` objects created inside composition without `remember`. +- Missing `equals()` on state data classes - a new instance with identical values still triggers recomposition without structural equality. +- Unstable lambda references when Strong Skipping is disabled. With Compose Compiler 2.0+ / Kotlin 2.0+ defaults, this is rare - verify before chasing. + +Stability annotations (`@Immutable`, `@Stable`) and Compose compiler metrics: [compose-patterns.md](/references/compose-patterns.md#stability-annotations-immutable-vs-stable), [android-performance.md](/references/android-performance.md). + +## Multi-Layer Boundary Debugging + +For issues spanning multiple layers (Repository, ViewModel, UI), temporarily instrument each +boundary to identify which layer produces the bad value: + +```kotlin +class UserRepository @Inject constructor( + private val api: UserApiService +) { + suspend fun fetchUser(id: String): Result { + Log.d("DEBUG_LAYER", "Repository: fetching user $id") + return runCatching { api.getUser(id) } + .also { Log.d("DEBUG_LAYER", "Repository: result=$it") } + } +} +``` + +Identify the layer that produces incorrect data, then investigate that layer in isolation. +Remove debug logging before committing. + +## ADB Quick Reference + +Route scripted install, launch, and black-box smoke checks through [testing.md](testing.md#agent-automation-adb-and-uiautomator); keep the snippets below for ad hoc debugging and `dumpsys`. + +```bash +# List connected devices +adb devices + +# Install APK +adb install -r app-debug.apk + +# Launch activity +adb shell am start -n com.example.app/.MainActivity + +# Clear app data +adb shell pm clear com.example.app + +# Take screenshot +adb exec-out screencap -p > screen.png + +# View running processes +adb shell ps | grep com.example + +# Inspect app's local storage +adb shell run-as com.example.app ls /data/data/com.example.app/ + +# Forward device port to host (for debugging network traffic) +adb forward tcp:8080 tcp:8080 + +# Show memory usage +adb shell dumpsys meminfo com.example.app + +# Show battery usage +adb shell dumpsys batterystats com.example.app + +# Show graphics performance +adb shell dumpsys gfxinfo com.example.app + +# Monitor frame rates +adb shell dumpsys gfxinfo com.example.app framestats +``` + +## Red Flags + +- Fixing a crash without reading the full `Caused by:` chain +- Guessing at an R8 issue without checking the mapping file +- Adding `Thread.sleep()` to "fix" an ANR or race condition +- Resolving a dependency conflict with `exclude` without understanding why the duplicate exists +- Wrapping a Compose bug in `key()` without understanding what triggers recomposition diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-graphics.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-graphics.md new file mode 100644 index 000000000..f3d49c8fb --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-graphics.md @@ -0,0 +1,1194 @@ +# Android Graphics & Icons + +Required: ship every icon as Material Symbols (drawable XML) or `ImageVector`. Custom drawing goes through `Canvas` / `Modifier.drawWithCache`. Never depend on the deprecated `androidx.compose.material.icons` artifact. + +## Table of Contents +1. [Material Symbols Icons](#material-symbols-icons) +2. [Adaptive Launcher Icons](#adaptive-launcher-icons) +3. [ImageVector Patterns](#imagevector-patterns) +4. [Custom Drawing with Canvas](#custom-drawing-with-canvas) +5. [Performance Optimizations](#performance-optimizations) + +## Material Symbols Icons + +Use Material Symbols (drawable XML) for every standard glyph. Avoid `androidx.compose.material.icons.*`: it is deprecated, ships M2 visuals, and inflates build time. + +### Downloading Icons + +Iconify API (preferred for automation): + +```bash +# Download icon as SVG using curl +curl -o app/src/main/res/drawable/ic_lock.xml \ + "https://api.iconify.design/material-symbols:lock.svg?download=true" + +curl -o app/src/main/res/drawable/ic_person.xml \ + "https://api.iconify.design/material-symbols:person.svg?download=true" + +curl -o app/src/main/res/drawable/ic_settings.xml \ + "https://api.iconify.design/material-symbols:settings.svg?download=true" + +# Outlined variant +curl -o app/src/main/res/drawable/ic_home_outlined.xml \ + "https://api.iconify.design/material-symbols:home-outline.svg?download=true" +``` + +Manual fallback: download SVG from https://fonts.google.com/icons, convert to a Vector Drawable via Android Studio (`res/drawable` → New → Vector Asset → Local file) or https://svg2vector.com/, place under `app/src/main/res/drawable/`. + +### Usage in Compose + +```kotlin +import androidx.compose.foundation.Image +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp + +@Composable +fun MaterialSymbolExample() { + Icon( + painter = painterResource(R.drawable.ic_lock), + contentDescription = stringResource(R.string.lock_icon), + modifier = Modifier.size(24.dp), + tint = Color.Unspecified // Use SVG colors + ) + + Icon( + painter = painterResource(R.drawable.ic_settings), + contentDescription = stringResource(R.string.settings_icon), + tint = MaterialTheme.colorScheme.primary + ) +} +``` + +Forbidden: `androidx.compose.material.icons.Icons.*` (e.g. `Icons.Default.Lock`). The artifact is unmaintained, ships M2 visuals, and inflates build time. + +### Icon Organization + +```kotlin +// app/src/main/kotlin/com/example/app/ui/icons/AppIcons.kt +object AppIcons { + val Lock = R.drawable.ic_lock + val Person = R.drawable.ic_person + val Settings = R.drawable.ic_settings + val Home = R.drawable.ic_home + val Info = R.drawable.ic_info +} + +// Usage +Icon( + painter = painterResource(AppIcons.Lock), + contentDescription = stringResource(R.string.lock_icon) +) +``` + +## Adaptive Launcher Icons + +Launcher icons are **adaptive** on API 26+: foreground and background layers mask to different shapes per OEM. + +**Key specs** + +| Item | Value | +|------------------------|------------------------------------------------------------| +| Layer canvas | 108 x 108 dp per layer (foreground and background) | +| Safe zone (full asset) | Keep critical logo inside center **66 dp** diameter circle | +| Logo artwork | Often ~48-66 dp so it is not clipped by masks | +| Monochrome | API 33+ optional monochrome layer for themed icons | + +Place `mipmap-anydpi-v26/ic_launcher.xml` with `` pointing at foreground and background drawables. Provide legacy mipmaps (mdpi through xxxhdpi) for older APIs as needed. + +See [Adaptive icons](https://developer.android.com/develop/ui/views/launch/icon_design_adaptive) for exports from design tools. + +## ImageVector Patterns + +Use `ImageVector` for icons that must be parameterized at runtime (themed colors, dynamic counts, generated paths). Use Material Symbols drawables for everything static. + +### Basic ImageVector Creation + +```kotlin +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val CustomCheckIcon: ImageVector = ImageVector.Builder( + name = "CustomCheck", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f +).apply { + path( + fill = SolidColor(Color.Black), + stroke = null, + strokeLineWidth = 0f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 4f, + pathFillType = PathFillType.NonZero + ) { + moveTo(9f, 16.17f) + lineTo(4.83f, 12f) + lineToRelative(-1.42f, 1.41f) + lineTo(9f, 19f) + lineTo(21f, 7f) + lineToRelative(-1.41f, -1.41f) + close() + } +}.build() +``` + +### PathData DSL + +Compose's PathData provides SVG-like commands: + +```kotlin +import androidx.compose.ui.graphics.vector.PathBuilder + +fun PathBuilder.drawCircle(cx: Float, cy: Float, radius: Float) { + moveTo(cx + radius, cy) + // Approximate circle with cubic Bézier curves + val c = 0.552284749831f * radius + curveTo(cx + radius, cy + c, cx + c, cy + radius, cx, cy + radius) + curveTo(cx - c, cy + radius, cx - radius, cy + c, cx - radius, cy) + curveTo(cx - radius, cy - c, cx - c, cy - radius, cx, cy - radius) + curveTo(cx + c, cy - radius, cx + radius, cy - c, cx + radius, cy) + close() +} + +val CircleIcon: ImageVector = ImageVector.Builder( + name = "Circle", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f +).apply { + path(fill = SolidColor(Color.Blue)) { + drawCircle(12f, 12f, 10f) + } +}.build() +``` + +### PathData Commands Reference + +| Command | Description | Example | +|--------------------------|-------------------------------|-------------------------------------------------| +| `moveTo(x, y)` | Move pen without drawing | `moveTo(10f, 10f)` | +| `lineTo(x, y)` | Draw line to point | `lineTo(20f, 20f)` | +| `horizontalLineTo(x)` | Horizontal line | `horizontalLineTo(50f)` | +| `verticalLineTo(y)` | Vertical line | `verticalLineTo(50f)` | +| `curveTo(...)` | Cubic Bézier curve (absolute) | `curveTo(10f, 20f, 30f, 40f, 50f, 60f)` | +| `curveToRelative(...)` | Cubic Bézier curve (relative) | `curveToRelative(10f, 20f, 30f, 40f, 50f, 60f)` | +| `reflectiveCurveTo(...)` | Smooth curve continuation | `reflectiveCurveTo(30f, 40f, 50f, 60f)` | +| `quadTo(...)` | Quadratic Bézier curve | `quadTo(30f, 20f, 50f, 40f)` | +| `arcTo(...)` | Elliptical arc | `arcTo(10f, 10f, 0f, false, true, 20f, 20f)` | +| `close()` | Close path to start | `close()` | + +### Dynamic Icon Generation + +Generate icons programmatically with parameters: + +```kotlin +fun createBadgeIcon(count: Int, backgroundColor: Color): ImageVector { + return ImageVector.Builder( + name = "Badge", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + // Background circle + path(fill = SolidColor(backgroundColor)) { + moveTo(12f, 2f) + curveTo(6.48f, 2f, 2f, 6.48f, 2f, 12f) + curveTo(2f, 17.52f, 6.48f, 22f, 12f, 22f) + curveTo(17.52f, 22f, 22f, 17.52f, 22f, 12f) + curveTo(22f, 6.48f, 17.52f, 2f, 12f, 2f) + close() + } + + // You could add text rendering here for the count + // (though for actual text, use Text composable overlays) + }.build() +} + +@Composable +fun NotificationBadge(count: Int) { + val badgeColor = if (count > 99) Color.Red else MaterialTheme.colorScheme.primary + + Image( + imageVector = createBadgeIcon(count, badgeColor), + contentDescription = "$count notifications" + ) +} +``` + +### Icon Collections + +Organize custom icons in a centralized object: + +```kotlin +// core/ui/icons/CustomIcons.kt +object CustomIcons { + val Zap: ImageVector by lazy { + ImageVector.Builder( + name = "Zap", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path(fill = SolidColor(Color(0xFFFFD700))) { // Gold + moveTo(13f, 2f) + lineTo(3f, 14f) + horizontalLineTo(12f) + lineTo(11f, 22f) + lineTo(21f, 10f) + horizontalLineTo(12f) + close() + } + }.build() + } + + val Relay: ImageVector by lazy { + ImageVector.Builder( + name = "Relay", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + // Relay icon paths + path(fill = SolidColor(Color.Black)) { + moveTo(12f, 2f) + lineTo(2f, 7f) + verticalLineTo(17f) + lineTo(12f, 22f) + lineTo(22f, 17f) + verticalLineTo(7f) + close() + } + }.build() + } +} + +// Usage +Icon(CustomIcons.Zap, contentDescription = "Lightning") +Icon(CustomIcons.Relay, contentDescription = "Relay indicator") +``` + +### Themed Icons + +Parameterize colors for theme adaptation: + +```kotlin +@Composable +fun ThemedIcon( + modifier: Modifier = Modifier, + contentDescription: String? +) { + val primary = MaterialTheme.colorScheme.primary + val surface = MaterialTheme.colorScheme.surface + + val themedIcon = remember(primary, surface) { + ImageVector.Builder( + name = "ThemedIcon", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + // Background + path(fill = SolidColor(surface)) { + moveTo(12f, 2f) + curveTo(6.48f, 2f, 2f, 6.48f, 2f, 12f) + curveTo(2f, 17.52f, 6.48f, 22f, 12f, 22f) + curveTo(17.52f, 22f, 22f, 17.52f, 22f, 12f) + curveTo(22f, 6.48f, 17.52f, 2f, 12f, 2f) + close() + } + + // Foreground + path(fill = SolidColor(primary)) { + moveTo(12f, 6f) + lineTo(18f, 12f) + lineTo(12f, 18f) + lineTo(6f, 12f) + close() + } + }.build() + } + + Image( + imageVector = themedIcon, + contentDescription = contentDescription, + modifier = modifier + ) +} +``` + +### Layered Icons with Alpha + +Build complex icons with multiple layers: + +```kotlin +val LayeredIcon: ImageVector = ImageVector.Builder( + name = "Layered", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f +).apply { + // Layer 1: Background (bottom) + path(fill = SolidColor(Color.White)) { + moveTo(0f, 0f) + lineTo(24f, 0f) + lineTo(24f, 24f) + lineTo(0f, 24f) + close() + } + + // Layer 2: Shadow + path( + fill = SolidColor(Color.Black), + fillAlpha = 0.2f + ) { + moveTo(13f, 13f) + curveTo(13f, 15.76f, 10.76f, 18f, 8f, 18f) + curveTo(5.24f, 18f, 3f, 15.76f, 3f, 13f) + curveTo(3f, 10.24f, 5.24f, 8f, 8f, 8f) + curveTo(10.76f, 8f, 13f, 10.24f, 13f, 13f) + close() + } + + // Layer 3: Main shape + path(fill = SolidColor(Color.Blue)) { + moveTo(12f, 12f) + curveTo(12f, 14.76f, 9.76f, 17f, 7f, 17f) + curveTo(4.24f, 17f, 2f, 14.76f, 2f, 12f) + curveTo(2f, 9.24f, 4.24f, 7f, 7f, 7f) + curveTo(9.76f, 7f, 12f, 9.24f, 12f, 12f) + close() + } + + // Layer 4: Highlight + path( + fill = SolidColor(Color.White), + fillAlpha = 0.3f + ) { + moveTo(9f, 10f) + curveTo(9f, 11.1f, 8.1f, 12f, 7f, 12f) + curveTo(5.9f, 12f, 5f, 11.1f, 5f, 10f) + curveTo(5f, 8.9f, 5.9f, 8f, 7f, 8f) + curveTo(8.1f, 8f, 9f, 8.9f, 9f, 10f) + close() + } + + // Layer 5: Outline (top) + path( + fill = null, + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f + ) { + moveTo(12f, 12f) + curveTo(12f, 14.76f, 9.76f, 17f, 7f, 17f) + curveTo(4.24f, 17f, 2f, 14.76f, 2f, 12f) + curveTo(2f, 9.24f, 4.24f, 7f, 7f, 7f) + curveTo(9.76f, 7f, 12f, 9.24f, 12f, 12f) + close() + } +}.build() +``` + +**Render order**: Bottom to top (first path = bottom layer) + +## Custom Drawing with Canvas + +For complex graphics beyond icons, use Compose's Canvas APIs. + +### Drawing Modifiers + +#### `Modifier.drawWithContent` + +Draw behind or in front of composable content: + +```kotlin +@Composable +fun GradientText(text: String) { + val gradient = Brush.linearGradient( + colors = listOf(Color.Blue, Color.Cyan, Color.Green) + ) + + Text( + text = text, + style = MaterialTheme.typography.headlineLarge, + modifier = Modifier.drawWithContent { + drawContent() // Draw the text first + + // Draw gradient overlay + drawRect( + brush = gradient, + blendMode = BlendMode.SrcAtop + ) + } + ) +} +``` + +#### `Modifier.drawBehind` + +Draw behind composable content: + +```kotlin +@Composable +fun HighlightedText(text: String) { + Text( + text = text, + modifier = Modifier.drawBehind { + val cornerRadius = 8.dp.toPx() + drawRoundRect( + color = Color.Yellow.copy(alpha = 0.3f), + cornerRadius = CornerRadius(cornerRadius) + ) + } + ) +} +``` + +#### `Modifier.drawWithCache` + +Cache drawing operations for better performance: + +```kotlin +@Composable +fun ComplexBackground() { + Box( + modifier = Modifier + .fillMaxSize() + .drawWithCache { + val gradient = Brush.radialGradient( + colors = listOf(Color.Blue, Color.Transparent), + center = Offset(size.width / 2, size.height / 2), + radius = size.maxDimension / 2 + ) + + onDrawBehind { + drawRect(gradient) + } + } + ) +} +``` + +### Canvas Composable + +Full control over drawing: + +```kotlin +@Composable +fun CustomChart(data: List) { + Canvas(modifier = Modifier.fillMaxSize()) { + val barWidth = size.width / data.size + val maxValue = data.maxOrNull() ?: 1f + + data.forEachIndexed { index, value -> + val barHeight = (value / maxValue) * size.height + + drawRect( + color = Color.Blue, + topLeft = Offset( + x = index * barWidth, + y = size.height - barHeight + ), + size = Size( + width = barWidth * 0.8f, + height = barHeight + ) + ) + } + } +} +``` + +### Advanced Canvas Techniques + +#### Clipping + +```kotlin +Canvas(modifier = Modifier.size(200.dp)) { + // Clip to circle + clipPath(Path().apply { + addOval(Rect(0f, 0f, size.width, size.height)) + }) { + // Everything drawn here is clipped to circle + drawRect( + brush = Brush.linearGradient( + colors = listOf(Color.Red, Color.Blue) + ) + ) + } +} +``` + +#### Transformations + +```kotlin +Canvas(modifier = Modifier.size(200.dp)) { + // Rotate + rotate(45f, pivot = center) { + drawRect( + color = Color.Blue, + size = Size(100f, 100f) + ) + } + + // Scale + scale(1.5f, pivot = center) { + drawCircle( + color = Color.Red, + radius = 50f, + center = center + ) + } + + // Translate + translate(left = 50f, top = 50f) { + drawLine( + color = Color.Green, + start = Offset.Zero, + end = Offset(100f, 100f), + strokeWidth = 5f + ) + } +} +``` + +#### Custom Shapes with Path + +```kotlin +@Composable +fun StarShape() { + Canvas(modifier = Modifier.size(100.dp)) { + val path = Path().apply { + val centerX = size.width / 2 + val centerY = size.height / 2 + val outerRadius = size.minDimension / 2 + val innerRadius = outerRadius * 0.4f + val points = 5 + + for (i in 0 until points * 2) { + val radius = if (i % 2 == 0) outerRadius else innerRadius + val angle = (i * Math.PI / points).toFloat() + val x = centerX + radius * cos(angle) + val y = centerY + radius * sin(angle) + + if (i == 0) moveTo(x, y) + else lineTo(x, y) + } + close() + } + + drawPath( + path = path, + color = Color(0xFFFFD700), // Gold + style = Fill + ) + + drawPath( + path = path, + color = Color.Black, + style = Stroke(width = 2.dp.toPx()) + ) + } +} +``` + +#### Blend Modes + +```kotlin +Canvas(modifier = Modifier.size(200.dp)) { + // Draw two overlapping circles with blend mode + drawCircle( + color = Color.Red, + radius = 80f, + center = Offset(60f, 100f) + ) + + drawCircle( + color = Color.Blue, + radius = 80f, + center = Offset(140f, 100f), + blendMode = BlendMode.Multiply // Try different blend modes + ) +} +``` + +**Common Blend Modes:** +- `BlendMode.Screen` - Additive blending for glow effects +- `BlendMode.Multiply` - Darkening/shadow effects +- `BlendMode.SrcAtop` - Mask content to layer below +- `BlendMode.Plus` - Additive color (brightening) +- `BlendMode.Overlay` - Combination of multiply and screen +- `BlendMode.Lighten` - Keep lighter pixels +- `BlendMode.Darken` - Keep darker pixels + +### Glow Effects with Radial Gradients + +Create dynamic glow effects using radial gradients and `BlendMode.Screen`: + +```kotlin +@Composable +fun GlowEffect( + glowColor: Color, + glowIntensity: Float = 0.6f, + modifier: Modifier = Modifier +) { + Canvas(modifier = modifier.fillMaxSize()) { + val center = Offset(size.width / 2f, size.height / 2f) + val radius = size.minDimension / 2f + + // Outer glow + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + glowColor.copy(alpha = 0.6f * glowIntensity), + glowColor.copy(alpha = 0.2f * glowIntensity), + Color.Transparent + ), + center = center, + radius = radius * 1.2f + ), + radius = radius * 1.5f, + center = center, + blendMode = BlendMode.Screen // Additive blending for glow + ) + + // Inner highlight + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + Color.White.copy(alpha = 0.1f * glowIntensity), + Color.Transparent + ), + center = center, + radius = radius * 0.5f + ), + radius = radius * 0.8f, + center = center, + blendMode = BlendMode.Screen + ) + } +} +``` + +### Animated Pulsing Glow + +Combine infinite animation with glow effects: + +```kotlin +@Composable +fun PulsingGlow( + glowColor: Color, + modifier: Modifier = Modifier +) { + val infiniteTransition = rememberInfiniteTransition(label = "glow_pulse") + val pulseIntensity by infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 0.9f, + animationSpec = infiniteRepeatable( + animation = tween(2200, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "pulse" + ) + + Canvas(modifier = modifier.fillMaxSize()) { + val center = Offset(size.width / 2f, size.height / 2f) + val baseRadius = size.minDimension / 2f + val animatedRadius = baseRadius * (1f + 0.2f * pulseIntensity) + + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + glowColor.copy(alpha = 0.6f * pulseIntensity), + glowColor.copy(alpha = 0.2f * pulseIntensity), + Color.Transparent + ), + center = center, + radius = animatedRadius + ), + radius = animatedRadius * 1.2f, + center = center, + blendMode = BlendMode.Screen + ) + } +} +``` + +### Multi-Color Glow Pattern + +Position multiple colored glows in a circular arrangement: + +```kotlin +@Composable +fun MultiColorGlow( + colors: List, + pulseIntensity: Float, + modifier: Modifier = Modifier +) { + Canvas(modifier = modifier.fillMaxSize()) { + val center = Offset(size.width / 2f, size.height / 2f) + val radius = size.minDimension / 2f + val spread = radius * 0.3f + + colors.forEachIndexed { index, color -> + // Position glows in a circle using trigonometry + val angle = 2f * Math.PI.toFloat() * index / colors.size + val colorCenter = Offset( + center.x + cos(angle) * radius * 0.2f, + center.y + sin(angle) * radius * 0.2f + ) + + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + color.copy(alpha = 0.6f * pulseIntensity), + color.copy(alpha = 0.2f * pulseIntensity), + Color.Transparent + ), + center = colorCenter, + radius = radius * 0.6f + ), + radius = radius * 0.8f, + center = colorCenter, + blendMode = BlendMode.Screen + ) + } + + // Overall white glow overlay + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + Color.White.copy(alpha = 0.05f * pulseIntensity), + Color.Transparent + ), + center = center, + radius = radius * 0.8f + ), + radius = radius * 1.2f, + center = center, + blendMode = BlendMode.Screen + ) + } +} +``` + +### Color Extraction from Images + +Use Android Palette API to extract colors from images: + +```kotlin +import androidx.palette.graphics.Palette +import android.graphics.Bitmap + +/** + * Extracts vibrant color from a bitmap + */ +fun extractVibrantColor(bitmap: Bitmap, isDark: Boolean = true): Color { + // Convert hardware bitmap to software bitmap if needed + val softwareBitmap = if (bitmap.config == Bitmap.Config.HARDWARE) { + bitmap.copy(Bitmap.Config.ARGB_8888, false) + } else { + bitmap + } + + val palette = Palette.from(softwareBitmap).generate() + + // Use vibrant swatches when sampling wallpaper colors + val vibrantSwatch = if (isDark) { + palette.darkVibrantSwatch + ?: palette.vibrantSwatch + ?: palette.dominantSwatch + } else { + palette.lightVibrantSwatch + ?: palette.vibrantSwatch + ?: palette.dominantSwatch + } + + return if (vibrantSwatch != null) { + Color(vibrantSwatch.rgb) + } else { + Color(0xFF6B6B6B) // Fallback + } +} + +/** + * Extract colors from different regions of the image + */ +fun extractMultipleColorsFromRegions( + bitmap: Bitmap, + numberOfRegions: Int +): List { + val colors = mutableListOf() + + // Define regions based on grid layout + val regions = when (numberOfRegions) { + 4 -> listOf( + android.graphics.Rect(0, 0, bitmap.width / 2, bitmap.height / 2), // Top-left + android.graphics.Rect(bitmap.width / 2, 0, bitmap.width, bitmap.height / 2), // Top-right + android.graphics.Rect(0, bitmap.height / 2, bitmap.width / 2, bitmap.height), // Bottom-left + android.graphics.Rect(bitmap.width / 2, bitmap.height / 2, bitmap.width, bitmap.height) // Bottom-right + ) + 6 -> listOf( + android.graphics.Rect(0, 0, bitmap.width / 2, bitmap.height / 3), // Top-left + android.graphics.Rect(bitmap.width / 2, 0, bitmap.width, bitmap.height / 3), // Top-right + android.graphics.Rect(0, bitmap.height / 3, bitmap.width / 2, 2 * bitmap.height / 3), // Middle-left + android.graphics.Rect(bitmap.width / 2, bitmap.height / 3, bitmap.width, 2 * bitmap.height / 3), // Middle-right + android.graphics.Rect(0, 2 * bitmap.height / 3, bitmap.width / 2, bitmap.height), // Bottom-left + android.graphics.Rect(bitmap.width / 2, 2 * bitmap.height / 3, bitmap.width, bitmap.height) // Bottom-right + ) + else -> listOf(android.graphics.Rect(0, 0, bitmap.width, bitmap.height)) + } + + regions.forEach { region -> + val subBitmap = Bitmap.createBitmap( + bitmap, + region.left, + region.top, + region.width(), + region.height() + ) + colors.add(extractVibrantColor(subBitmap)) + subBitmap.recycle() + } + + return colors.distinct() +} +``` + +### Dynamic Size Tracking + +Get composable size for drawing calculations: + +```kotlin +@Composable +fun DynamicSizeCanvas() { + var containerSize by remember { mutableStateOf(null) } + + Box( + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { coordinates -> + containerSize = Size( + coordinates.size.width.toFloat(), + coordinates.size.height.toFloat() + ) + } + ) { + containerSize?.let { size -> + Canvas(modifier = Modifier.fillMaxSize()) { + // Use size for calculations + val center = Offset(size.width / 2f, size.height / 2f) + val radius = minOf(size.width, size.height) / 2f + + drawCircle( + color = Color.Blue, + radius = radius, + center = center + ) + } + } + } +} +``` + +### Image Loading with Coil3 + +#### AsyncImage (Primary API) + +Use `AsyncImage` for the vast majority of cases. It resolves image size from layout constraints +automatically, avoiding oversized bitmap loading. + +```kotlin +AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data("https://example.com/avatar.jpg") + .crossfade(true) + .build(), + contentDescription = stringResource(R.string.user_avatar), + contentScale = ContentScale.Crop, + placeholder = painterResource(R.drawable.ic_placeholder), + error = painterResource(R.drawable.ic_error), + modifier = Modifier + .size(64.dp) + .clip(CircleShape) +) +``` + +#### SubcomposeAsyncImage (Custom State Composables) + +Use only when you need fully custom composables for loading, success, and error states. +**Never use inside `LazyColumn` / `LazyRow`** - subcomposition is significantly slower than +regular composition and causes scroll jank. + +```kotlin +SubcomposeAsyncImage( + model = "https://example.com/hero.jpg", + contentDescription = null +) { + when (painter.state) { + is AsyncImagePainter.State.Loading -> CircularProgressIndicator() + is AsyncImagePainter.State.Error -> Icon(Icons.Default.BrokenImage, null) + else -> SubcomposeAsyncImageContent() + } +} +``` + +#### rememberAsyncImagePainter (Low-Level) + +Use only when a `Painter` is strictly required (e.g., `Canvas`, `Icon`, or a custom draw +operation). Unlike `AsyncImage`, it does **not** infer display size - without an explicit +`.size()`, it loads the image at original dimensions, wasting memory. + +```kotlin +val painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(LocalContext.current) + .data("https://example.com/image.jpg") + .size(Size.ORIGINAL) + .build() +) +Image(painter = painter, contentDescription = null) +``` + +#### ImageRequest Configuration + +```kotlin +ImageRequest.Builder(context) + .data(imageUrl) + .crossfade(300) + .size(200, 200) + .scale(Scale.CROP) + .transformations(CircleCropTransformation()) + .memoryCachePolicy(CachePolicy.ENABLED) + .diskCachePolicy(CachePolicy.ENABLED) + .build() +``` + +#### Hilt ImageLoader Setup + +Provide a single `ImageLoader` instance app-wide to share disk and memory caches: + +```kotlin +@Module +@InstallIn(SingletonComponent::class) +object ImageModule { + + @Provides + @Singleton + fun provideImageLoader(@ApplicationContext context: Context): ImageLoader = + ImageLoader.Builder(context) + .crossfade(true) + .respectCacheHeaders(false) + .build() +} +``` + +Pass it to `AsyncImage` via injection or `CompositionLocal`: + +```kotlin +AsyncImage( + model = url, + contentDescription = null, + imageLoader = imageLoader +) +``` + +#### Which API to Use + +- **Standard image loading** → `AsyncImage` +- **Need `Painter` for `Canvas` / `Icon`** → `rememberAsyncImagePainter` + explicit `.size()` +- **Custom loading/error composables** → `SubcomposeAsyncImage` (never in lists) +- **Decorative image** → `contentDescription = null` + +#### Color Extraction from Loaded Images + +```kotlin +import coil3.ImageLoader +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import coil3.request.allowHardware + +suspend fun loadImageAndExtractColor( + context: Context, + imageUrl: String +): Color? { + return try { + val imageLoader = ImageLoader(context) + val request = ImageRequest.Builder(context) + .data(imageUrl) + .allowHardware(false) // Required for Palette API + .build() + + val result = imageLoader.execute(request) + if (result is SuccessResult) { + val drawable = result.image.asDrawable(context.resources) + val bitmap = drawable.toBitmap() + extractVibrantColor(bitmap) + } else { + null + } + } catch (e: Exception) { + null + } +} + +@Composable +fun ImageWithExtractedGlow(imageUrl: String) { + val context = LocalContext.current + var glowColor by remember(imageUrl) { mutableStateOf(null) } + + LaunchedEffect(imageUrl) { + glowColor = loadImageAndExtractColor(context, imageUrl) + } + + Box { + // Glow effect using extracted color + glowColor?.let { color -> + PulsingGlow(glowColor = color, modifier = Modifier.matchParentSize()) + } + + // Image on top + AsyncImage( + model = imageUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize() + ) + } +} +``` + +### Blend Modes + +### Performance: drawWithCache vs drawBehind + +`drawWithCache` allocates expensive objects (`Brush`, `Path`, gradients) once and reuses them across draws. `drawBehind` re-runs its block on every frame; reserve it for cheap, layout-dependent operations. + +```kotlin +@Composable +fun CachedDrawing() { + Box( + modifier = Modifier.drawWithCache { + val gradient = createExpensiveGradient() + val path = createComplexPath() + + onDrawBehind { + drawPath(path, brush = gradient) + } + } + ) +} +``` + +## Performance Optimizations + +### Icon Caching + +Cache dynamically generated ImageVectors: + +```kotlin +object IconCache { + private val cache = mutableMapOf() + + fun getOrCreate(key: String, builder: () -> ImageVector): ImageVector { + return cache.getOrPut(key, builder) + } +} + +@Composable +fun CachedIcon(userId: String) { + val icon = remember(userId) { + IconCache.getOrCreate(userId) { + generateUserIcon(userId) + } + } + + Image(imageVector = icon, contentDescription = "User avatar") +} +``` + +### Avoid Recomposition + +Use `remember` and `derivedStateOf` appropriately: + +```kotlin +@Composable +fun AnimatedIcon(isActive: Boolean) { + // CORRECT: Icon only recreated when isActive changes + val icon = remember(isActive) { + createAnimatedIcon(isActive) + } + + Image(imageVector = icon, contentDescription = null) +} + +@Composable +fun DerivedIcon(data: List) { + // CORRECT: Icon only recreated when sum changes, not when list instance changes + val icon = remember { + derivedStateOf { createIcon(data.sum()) } + }.value + + Image(imageVector = icon, contentDescription = null) +} +``` + +### Lazy Icon Loading + +Don't create all icons upfront: + +```kotlin +object AppIcons { + // CORRECT: Lazy initialization + val Home: ImageVector by lazy { createHomeIcon() } + val Settings: ImageVector by lazy { createSettingsIcon() } + val Profile: ImageVector by lazy { createProfileIcon() } + + // WRONG: Avoid Eager initialization + // val All = listOf(createHomeIcon(), createSettingsIcon(), ...) // Creates all immediately +} +``` + +## Rules + +Required: +- Source standard glyphs from Material Symbols (drawable XML). +- Reserve `ImageVector` for programmatic / themed / dynamic icons; cache results behind `by lazy` or `remember(keys)`. +- Wrap expensive draw setup in `Modifier.drawWithCache`; keep `Modifier.drawBehind` for cheap, per-frame work only. +- Layer paths bottom-to-top inside one `ImageVector.Builder`; close every sub-path with `close()`. +- Centralize icon references in an `AppIcons` / `CustomIcons` object. +- Resolve theme colors via `MaterialTheme.colorScheme.*` and pass them in; never hard-code theme-specific colors inside an icon definition. +- Use `AsyncImage` for network/disk images; `SubcomposeAsyncImage` only outside lazy lists; `rememberAsyncImagePainter` only when a `Painter` is required (with explicit `.size()`). + +Forbidden: +- `androidx.compose.material.icons.Icons.*` and any artifact under `androidx.compose.material.icons:*`. +- Building an `ImageVector` inside a `@Composable` without `remember` / `by lazy`. +- Replacing a standard Material Symbol with a hand-rolled custom icon. +- Mixing absolute and relative path commands within the same path without reason. +- `SubcomposeAsyncImage` inside `LazyColumn` / `LazyRow` (causes scroll jank). + +## Additional Resources + +- [Material Symbols](https://fonts.google.com/icons) +- [Iconify API](https://api.iconify.design/) +- [Compose Graphics API](https://developer.android.com/jetpack/compose/graphics) +- [ImageVector Reference](https://developer.android.com/reference/kotlin/androidx/compose/ui/graphics/vector/ImageVector) +- [Canvas in Compose](https://developer.android.com/jetpack/compose/graphics/draw/overview) +- [SVG Path Commands](https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths) diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-i18n.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-i18n.md new file mode 100644 index 000000000..2bc7ecf48 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-i18n.md @@ -0,0 +1,1043 @@ +# Internationalization & Localization (i18n/l10n) + +Required for every user-visible string and date/time/number value in Compose: + +- Move all user-visible text to `strings.xml`. No hardcoded literals in composables. +- Use `LocalLayoutDirection` and `Modifier.padding(start = ..., end = ...)`. Never `left`/`right`. +- Format dates, times, currencies, and numbers via `kotlinx-datetime` + `NumberFormat.getInstance(locale)`. Never string-concatenate. +- Use `pluralStringResource` (or ICU `plurals`) for any quantity-bearing string. Never `"%d items"`. +- Test every screen with pseudo-locale `en-XA` and an RTL locale (`ar` or `he`). + +## String Resources + +### Basic String Resources + +```xml + + + My App + Welcome, %1$s! + Log In + Email address + + + + + Mi App + ¡Bienvenido, %1$s! + Iniciar sesión + Correo electrónico + + + + + برنامه من + خوش آمدید، %1$s! + ورود + آدرس ایمیل + +``` + +### Using String Resources in Compose + +```kotlin +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource + +@Composable +fun AuthLoginScreen() { + Column { + Text(stringResource(R.string.welcome_message, "John")) + + Button(onClick = { /* ... */ }) { + Text(stringResource(R.string.login_button)) + } + + OutlinedTextField( + value = email, + onValueChange = { email = it }, + label = { Text(stringResource(R.string.email_hint)) } + ) + } +} +``` + +### Parameterized Strings + +```xml + +Hello, %1$s! +%1$d of %2$d items selected +Downloading %1$s (%2$d%%) +``` + +```kotlin +@Composable +fun ProfileScreen(userName: String) { + Text(stringResource(R.string.user_greeting, userName)) +} + +@Composable +fun SelectionStatus(selected: Int, total: Int) { + Text(stringResource(R.string.items_selected, selected, total)) +} +``` + +## Plurals (Quantity Strings) + +### Defining Plurals + +```xml + + + + No notifications + 1 notification + %d notifications + + + + 1 minute ago + %d minutes ago + + + + + + + لا توجد إشعارات + إشعار واحد + إشعاران + %d إشعارات + %d إشعارًا + %d إشعار + + + + + + + %d уведомление + %d уведомления + %d уведомлений + %d уведомлений + + +``` + +### Using Plurals in Compose + +```kotlin +import androidx.compose.ui.res.pluralStringResource + +@Composable +fun NotificationBadge(count: Int) { + Text( + text = pluralStringResource( + R.plurals.notification_count, + count, + count + ) + ) +} + +@Composable +fun TimestampText(minutesAgo: Int) { + Text( + text = pluralStringResource( + R.plurals.minutes_ago, + minutesAgo, + minutesAgo + ) + ) +} +``` + +**Required:** the first `count` selects the plural branch; the second `count` fills `%d` in the formatted string. + +## RTL (Right-to-Left) Support + +### Automatic RTL in Compose + +Compose automatically handles RTL layout for most components. Enable RTL support in your manifest: + +```xml + + + +``` + +### Layout Direction Awareness + +```kotlin +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection + +@Composable +fun DirectionAwareContent() { + val layoutDirection = LocalLayoutDirection.current + + when (layoutDirection) { + LayoutDirection.Ltr -> { + // Left-to-Right specific layout + } + LayoutDirection.Rtl -> { + // Right-to-Left specific layout + } + } +} +``` + +### RTL-Friendly Modifiers + +```kotlin +@Composable +fun ProfileCard(user: User) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // Use start/end instead of left/right + Image( + painter = painterResource(R.drawable.avatar), + contentDescription = null, + modifier = Modifier + .size(48.dp) + .padding(end = 16.dp) // Automatically flips in RTL + ) + + Column( + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = user.name, + // Text alignment automatically adjusts + textAlign = TextAlign.Start + ) + } + } +} +``` + +**Required:** +- Use `padding(start = ...)` / `padding(end = ...)` instead of `left` / `right`. +- Use `Arrangement.Start` / `Arrangement.End` instead of `Left` / `Right`. +- Use `TextAlign.Start` / `TextAlign.End` instead of `Left` / `Right`. +- Mirror directional icons in RTL (`Modifier.mirror()` for custom artwork). + +### Force RTL for Testing + +```kotlin +import androidx.compose.runtime.CompositionLocalProvider + +@Preview +@Composable +fun PreviewRTL() { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { + ProfileCard(user = previewUser) + } +} +``` + +### Custom Mirroring for Icons + +```kotlin +import androidx.compose.ui.draw.scale + +@Composable +fun DirectionalIcon() { + val layoutDirection = LocalLayoutDirection.current + val mirrorMultiplier = if (layoutDirection == LayoutDirection.Rtl) -1f else 1f + + Icon( + painter = painterResource(R.drawable.ic_arrow_forward), + contentDescription = null, + modifier = Modifier.scale(scaleX = mirrorMultiplier, scaleY = 1f) + ) +} +``` + +## Date & Time Formatting + +Use `kotlinx-datetime` for locale-aware date/time formatting. + +### Dependencies + +```kotlin +// Already in assets/libs.versions.toml.template +implementation(libs.kotlinx.datetime) +``` + +### Formatting with kotlinx-datetime + +```kotlin +import kotlinx.datetime.* +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale + +class DateTimeFormatter { + fun formatDate( + instant: Instant, + locale: Locale = Locale.getDefault() + ): String { + val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault()) + val javaLocalDate = java.time.LocalDate.of( + localDateTime.year, + localDateTime.monthNumber, + localDateTime.dayOfMonth + ) + + return DateTimeFormatter + .ofLocalizedDate(FormatStyle.MEDIUM) + .withLocale(locale) + .format(javaLocalDate) + } + + fun formatTime( + instant: Instant, + locale: Locale = Locale.getDefault() + ): String { + val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault()) + val javaLocalTime = java.time.LocalTime.of( + localDateTime.hour, + localDateTime.minute + ) + + return DateTimeFormatter + .ofLocalizedTime(FormatStyle.SHORT) + .withLocale(locale) + .format(javaLocalTime) + } + + fun formatDateTime( + instant: Instant, + locale: Locale = Locale.getDefault() + ): String { + val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault()) + val javaLocalDateTime = java.time.LocalDateTime.of( + localDateTime.year, + localDateTime.monthNumber, + localDateTime.dayOfMonth, + localDateTime.hour, + localDateTime.minute + ) + + return DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.MEDIUM) + .withLocale(locale) + .format(javaLocalDateTime) + } + + // Relative time (e.g., "2 hours ago") + fun formatRelativeTime(instant: Instant): String { + val now = Clock.System.now() + val duration = now - instant + + return when { + duration.inWholeMinutes < 1 -> "Just now" + duration.inWholeMinutes < 60 -> "${duration.inWholeMinutes} minutes ago" + duration.inWholeHours < 24 -> "${duration.inWholeHours} hours ago" + duration.inWholeDays < 7 -> "${duration.inWholeDays} days ago" + else -> formatDate(instant) + } + } +} +``` + +### Using in Compose + +```kotlin +@Composable +fun PostTimestamp(timestamp: Instant) { + val formatter = remember { DateTimeFormatter() } + + Text( + text = remember(timestamp) { + formatter.formatRelativeTime(timestamp) + } + ) +} +``` + +### Relative Time with Plurals + +```xml + + + 1 minute ago + %d minutes ago + + + + 1 hour ago + %d hours ago + +``` + +```kotlin +@Composable +fun LocalizedRelativeTime(instant: Instant) { + val now = Clock.System.now() + val duration = now - instant + + val text = when { + duration.inWholeMinutes < 1 -> stringResource(R.string.just_now) + duration.inWholeMinutes < 60 -> { + val minutes = duration.inWholeMinutes.toInt() + pluralStringResource(R.plurals.minutes_ago, minutes, minutes) + } + duration.inWholeHours < 24 -> { + val hours = duration.inWholeHours.toInt() + pluralStringResource(R.plurals.hours_ago, hours, hours) + } + else -> { + val formatter = remember { DateTimeFormatter() } + formatter.formatDate(instant) + } + } + + Text(text) +} +``` + +## Currency Formatting + +```kotlin +import java.text.NumberFormat +import java.util.Currency +import java.util.Locale + +class CurrencyFormatter { + fun formatCurrency( + amount: Double, + currencyCode: String, + locale: Locale = Locale.getDefault() + ): String { + val formatter = NumberFormat.getCurrencyInstance(locale) + formatter.currency = Currency.getInstance(currencyCode) + return formatter.format(amount) + } + + fun formatCurrencyCompact( + amount: Double, + currencyCode: String, + locale: Locale = Locale.getDefault() + ): String { + return when { + amount >= 1_000_000 -> { + val millions = amount / 1_000_000 + "${formatCurrency(millions, currencyCode, locale)}M" + } + amount >= 1_000 -> { + val thousands = amount / 1_000 + "${formatCurrency(thousands, currencyCode, locale)}K" + } + else -> formatCurrency(amount, currencyCode, locale) + } + } +} +``` + +```kotlin +@Composable +fun PriceDisplay(amount: Double, currencyCode: String) { + val formatter = remember { CurrencyFormatter() } + + Text( + text = remember(amount, currencyCode) { + formatter.formatCurrency(amount, currencyCode) + } + ) +} +``` + +## Locale-Specific Resource Qualifiers + +### Common Qualifiers + +``` +res/ +├── values/ # Default (English) +├── values-es/ # Spanish +├── values-fa/ # Persian/Farsi (RTL) +├── values-ar/ # Arabic (RTL) +├── values-fr/ # French +├── values-de/ # German +├── values-ja/ # Japanese +├── values-zh-rCN/ # Chinese (Simplified) +├── values-zh-rTW/ # Chinese (Traditional) +├── values-pt-rBR/ # Portuguese (Brazil) +├── values-pt-rPT/ # Portuguese (Portugal) +├── values-night/ # Dark mode (all locales) +├── values-es-night/ # Spanish + Dark mode +├── values-fa-night/ # Persian + Dark mode +└── values-ar-night/ # Arabic + Dark mode +``` + +### Combining Qualifiers + +``` +res/ +├── drawable/ # Default +├── drawable-night/ # Dark mode +├── drawable-ldrtl/ # RTL layout direction +├── drawable-night-ldrtl/ # Dark mode + RTL +└── drawable-es/ # Spanish locale (if needed) +``` + +### String Arrays + +```xml + + + + Monday + Tuesday + Wednesday + Thursday + Friday + Saturday + Sunday + + + + + + + Lunes + Martes + Miércoles + Jueves + Viernes + Sábado + Domingo + + +``` + +```kotlin +@Composable +fun DayPicker() { + val days = LocalContext.current.resources.getStringArray(R.array.days_of_week) + + LazyColumn { + items(days) { day -> + Text(day) + } + } +} +``` + +## String Resource Ownership + +With `android.nonTransitiveRClass=true`, each module has its own R class containing only its own resources. Organize string resources by module responsibility: + +### Module Organization + +- **`core:common`** or **`core:domain`**: Generic error messages, result states used across multiple features + ```xml + + Unknown error occurred + Network connection failed + Request timed out + Loading... + ``` + +- **`core:ui`**: Shared UI component labels, accessibility descriptions, common actions + ```xml + + Back + Close + Save + Light + Dark + Loading content + ``` + +- **`feature:xxx`**: Feature-specific strings (screen titles, labels, messages unique to that feature) + ```xml + + Products + Product Details + No products available + Add to Cart + ``` + +### Cross-Module Resource Access + +When a feature needs to reference resources from another module: + +```kotlin +// feature/products/presentation/ProductsListView.kt +import com.example.core.ui.R as CoreUiR +import com.example.feature.products.R + +@Composable +fun ProductsListView(state: ProductsUiState) { + when (state) { + is Loading -> Text(stringResource(CoreUiR.string.loading)) + is Empty -> Text(stringResource(R.string.products_empty)) + is Error -> Text(stringResource(CoreUiR.string.error_unknown)) + is Success -> ProductsList(state.products) + } +} +``` + +**Required:** +- **Never duplicate strings** across modules, even if the English text matches. +- Use import aliases (`as CoreUiR`) when a file reads strings from multiple modules. +- Promote shared copy to `core:common` or `core:ui` when multiple features need the same key. +- Shared UI chrome strings live in `core:ui`; feature modules depend on that module instead of copying XML. +- Non-transitive R class wiring: [gradle-setup.md → Non-transitive R classes](/references/gradle-setup.md#non-transitive-r-classes). + +## Architecture Integration + +### Repository Layer + +```kotlin +// core/domain/model/LocalizedContent.kt +data class LocalizedContent( + val titleKey: String, + val descriptionKey: String, + val imageUrl: String +) + +// core/data/repository/ContentRepository.kt +interface ContentRepository { + suspend fun getContent(locale: Locale): Result> +} + +class ContentRepositoryImpl @Inject constructor( + private val contentApi: ContentApi, + private val contentDao: ContentDao +) : ContentRepository { + override suspend fun getContent(locale: Locale): Result> { + return try { + val languageCode = locale.language + val response = contentApi.getContent(languageCode) + contentDao.insertAll(response) + Result.success(response) + } catch (e: Exception) { + // Fallback to cached content + Result.success(contentDao.getAll()) + } + } +} +``` + +### ViewModel Layer + +```kotlin +@HiltViewModel +class ContentViewModel @Inject constructor( + private val contentRepository: ContentRepository, + private val savedStateHandle: SavedStateHandle +) : ViewModel() { + + private val _uiState = MutableStateFlow(ContentUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadContent() + } + + private fun loadContent() { + viewModelScope.launch { + val locale = Locale.getDefault() + contentRepository.getContent(locale) + .onSuccess { content -> + _uiState.value = ContentUiState.Success(content) + } + .onFailure { error -> + _uiState.value = ContentUiState.Error(error.message ?: "Unknown error") + } + } + } +} +``` + +### UI Layer + +```kotlin +@Composable +fun ContentScreen(viewModel: ContentViewModel = hiltViewModel()) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + when (val state = uiState) { + is ContentUiState.Loading -> LoadingIndicator() + is ContentUiState.Success -> ContentList(state.content) + is ContentUiState.Error -> ErrorMessage(state.message) + } +} +``` + +## Testing Localization + +### Testing Different Locales + +```kotlin +@RunWith(AndroidJUnit4::class) +class LocalizationTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun testEnglishStrings() { + setLocale(Locale.ENGLISH) + + composeTestRule.setContent { + AuthLoginScreen() + } + + composeTestRule + .onNodeWithText("Log In") + .assertIsDisplayed() + } + + @Test + fun testSpanishStrings() { + setLocale(Locale("es")) + + composeTestRule.setContent { + AuthLoginScreen() + } + + composeTestRule + .onNodeWithText("Iniciar sesión") + .assertIsDisplayed() + } + + @Test + fun testArabicRTL() { + setLocale(Locale("ar")) + + composeTestRule.setContent { + ProfileCard(previewUser) + } + + composeTestRule.onRoot().assertLayoutDirectionEquals(LayoutDirection.Rtl) + } + + private fun setLocale(locale: Locale) { + val config = Configuration( + InstrumentationRegistry.getInstrumentation() + .targetContext.resources.configuration + ) + config.setLocale(locale) + + val context = InstrumentationRegistry.getInstrumentation().targetContext + context.createConfigurationContext(config) + Locale.setDefault(locale) + } +} +``` + +### Testing Plurals + +```kotlin +@Test +fun testPlurals() { + composeTestRule.setContent { + Column { + NotificationBadge(count = 0) + NotificationBadge(count = 1) + NotificationBadge(count = 5) + } + } + + composeTestRule + .onNodeWithText("No notifications") + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("1 notification") + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("5 notifications") + .assertIsDisplayed() +} +``` + +### Parameterized Tests for Multiple Locales + +```kotlin +@RunWith(Parameterized::class) +class LocalizationParameterizedTest( + private val locale: Locale, + private val expectedText: String +) { + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data() = listOf( + arrayOf(Locale.ENGLISH, "Log In"), + arrayOf(Locale("es"), "Iniciar sesión"), + arrayOf(Locale("fr"), "Se connecter"), + arrayOf(Locale("de"), "Anmelden") + ) + } + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun testLoginButton() { + setLocale(locale) + + composeTestRule.setContent { + Button(onClick = {}) { + Text(stringResource(R.string.login_button)) + } + } + + composeTestRule + .onNodeWithText(expectedText) + .assertIsDisplayed() + } + + private fun setLocale(locale: Locale) { + val config = Configuration() + config.setLocale(locale) + val context = InstrumentationRegistry.getInstrumentation().targetContext + context.createConfigurationContext(config) + Locale.setDefault(locale) + } +} +``` + +### Screenshot Testing for RTL + +```kotlin +@Test +fun testRTLScreenshots() { + val locales = listOf( + Locale.ENGLISH, + Locale("ar"), // RTL + Locale("he") // RTL + ) + + locales.forEach { locale -> + setLocale(locale) + + composeTestRule.setContent { + ProfileScreen() + } + + // Take screenshot (using Screenshot Testing library) + composeTestRule + .onRoot() + .captureToImage() + .assertAgainstGolden("profile_screen_${locale.language}") + } +} +``` + +## Rules + +### String resources + +**Wrong:** +```kotlin +Text("Welcome to My App") +Button(onClick = {}) { Text("Submit") } +``` + +**Correct:** +```kotlin +Text(stringResource(R.string.welcome_message)) +Button(onClick = {}) { Text(stringResource(R.string.submit_button)) } +``` + +### Plurals for quantities + +**Wrong:** +```kotlin +val text = if (count == 1) "$count item" else "$count items" +``` + +**Correct:** +```kotlin +val text = pluralStringResource(R.plurals.item_count, count, count) +``` + +### No string concatenation + +**Wrong:** +```kotlin +Text("Hello " + userName + ", welcome back!") +``` + +**Correct:** +```xml +Hello %1$s, welcome back! +``` +```kotlin +Text(stringResource(R.string.welcome_back, userName)) +``` + +### Start/end layout + +**Wrong:** +```kotlin +Modifier.padding(left = 16.dp, right = 16.dp) +``` + +**Correct:** +```kotlin +Modifier.padding(start = 16.dp, end = 16.dp) +``` + +### RTL testing + +Always test with RTL locales (Arabic, Hebrew, Persian): +```kotlin +@Preview(locale = "ar") +@Composable +fun PreviewArabic() { + MyScreen() +} +``` + +### Locale-aware formatting + +**Wrong:** +```kotlin +Text("Price: $${amount}") +Text("Date: ${year}-${month}-${day}") +``` + +**Correct:** +```kotlin +Text(currencyFormatter.formatCurrency(amount, "USD")) +Text(dateFormatter.formatDate(instant)) +``` + +### Text expansion + +Some languages (German, Finnish) have longer words. Design UI with flexibility: +```kotlin +Button( + onClick = {}, + modifier = Modifier.widthIn(min = 120.dp) // Allow expansion +) { + Text( + text = stringResource(R.string.login_button), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) +} +``` + +### Translator context comments + +```xml + + + + Log In + + + Welcome, %1$s! + +``` + +### ICU MessageFormat + +For very complex plural rules, consider using ICU MessageFormat: +```xml + + + {count, plural, + =0 {No new notifications} + =1 {1 new notification} + other {# new notifications}} + + +``` + +### Relative measurements + +Some languages (Thai, Japanese) may need different line heights or text sizes: +```kotlin +Text( + text = stringResource(R.string.description), + style = MaterialTheme.typography.bodyMedium.copy( + // Use relative line height instead of absolute + lineHeight = 1.5.em + ) +) +``` + +## CI/CD Integration + +### Automated String Checks + +```yaml +# .github/workflows/i18n-check.yml +name: I18n Checks + +on: [pull_request] + +jobs: + check-strings: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check for hardcoded strings + run: | + # Find hardcoded strings in Kotlin files + if grep -r "Text(\"" app/src/main/java/; then + echo "Found hardcoded strings. Use stringResource() instead." + exit 1 + fi + + - name: Validate all translations exist + run: | + # Check that all string keys exist in all locales + ./scripts/validate_translations.sh +``` + +## Common Pitfalls + +### Pitfall: RTL skipped + +Always enable RTL and test with Arabic/Hebrew: +```xml + +``` + +### Pitfall: string concatenation + +This breaks word order in other languages. Always use parameterized strings. + +### Pitfall: hardcoded dates/times + +Always use locale-aware formatters. + +### Pitfall: English-only word order + +Different languages have different grammar rules. Use placeholders: +```xml + +%1$d items found + + +%1$d個のアイテムが見つかりました +``` + +### Pitfall: no expansion testing + +German and Finnish translations can be 30-40% longer. Test UI flexibility. + +## External Resources + +- [Android Localization](https://developer.android.com/guide/topics/resources/localization) +- [Supporting RTL Languages](https://developer.android.com/training/basics/supporting-devices/languages#CreateRtl) +- [ICU MessageFormat](https://unicode-org.github.io/icu/userguide/format_parse/messages/) +- [kotlinx-datetime](https://github.com/Kotlin/kotlinx-datetime) diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-media.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-media.md new file mode 100644 index 000000000..9a04604aa --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-media.md @@ -0,0 +1,109 @@ +# Android Media + +**Use when:** routing media or document picks, sharing app-owned `content` URIs, or implementing Media3 background playback at target SDK 37. + +Use [Picking media and documents](#picking-media-and-documents), [Sharing media and files](#sharing-media-and-files), and [Scoped storage and permissions](#scoped-storage-and-permissions) as indexes into [android-permissions.md](/references/android-permissions.md), [android-security.md](/references/android-security.md), and [android-notifications.md](/references/android-notifications.md). Implement playback under [Background media playback hardening (API 37)](#background-media-playback-hardening-api-37). + +Image loading: [android-graphics.md → Image Loading with Coil3](/references/android-graphics.md). Camera, screen recording, partial screen share: [android-security.md](/references/android-security.md). Playback notifications and PiP: [android-notifications.md](/references/android-notifications.md). + +## Table of Contents + +1. [Picking media and documents](#picking-media-and-documents) +2. [Sharing media and files](#sharing-media-and-files) +3. [Scoped storage and permissions](#scoped-storage-and-permissions) +4. [Background media playback hardening (API 37)](#background-media-playback-hardening-api-37) + +## Picking media and documents + +Use the table as an index only; contracts and samples sit in the linked rows. + +| Need | Route | +|------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Pick images or video without broad `READ_MEDIA_*` when UX allows | [android-permissions.md → Photo Picker (Preferred for Media on Android 13+)](/references/android-permissions.md#photo-picker-preferred-for-media-on-android-13) | +| Generic MIME or documents (`GetContent`, `OpenDocument`, multi-select) | [android-permissions.md → Requesting Runtime Permissions in Compose](/references/android-permissions.md#requesting-runtime-permissions-in-compose) | + +## Sharing media and files + +| Need | Route | +|------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------| +| `content://` backed by app files for another package | [android-security.md → FileProvider for Secure File Sharing](/references/android-security.md#fileprovider-for-secure-file-sharing) | +| `ACTION_SEND` / `ACTION_SEND_MULTIPLE` with `content` URIs | [android-security.md → Forward-compatible URI grants (Android 18 prep)](/references/android-security.md#forward-compatible-uri-grants-android-18-prep) | +| System chooser UX for text or streams | [android-notifications.md → System sharesheet](/references/android-notifications.md#system-sharesheet) | + +## Scoped storage and permissions + +**Use:** [android-permissions.md](/references/android-permissions.md) for scoped-storage capability matrix; [android-security.md](/references/android-security.md) for outbound `content` trust boundaries and profile edge cases. + +## Background media playback hardening (API 37) + +Required: at target SDK 37, every background media playback session - audio or video - runs inside a Media3 `MediaSessionService` with a `mediaPlayback` foreground service type. Standalone `MediaPlayer` / `AudioTrack` background audio is silently dropped and `requestAudioFocus()` returns `AUDIOFOCUS_REQUEST_FAILED`. + +The same rule covers audio-only, video-only, and audio-with-video playback. The audio-focus enforcement bullet applies only when audio is playing. + +Required: +- Subclass `MediaSessionService` and build a `MediaSession` from a Media3 `Player` (`ExoPlayer` is the default; works for audio, video, or both). +- Set `android:foregroundServiceType="mediaPlayback"` on the service in the manifest. +- Declare `android.permission.FOREGROUND_SERVICE` and `android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK`. +- Release the `MediaSession` and the underlying `Player` in `onDestroy()`. A leaked session leaves an undismissible playback notification. +- Stop the service when playback ends: `Player.STATE_ENDED` -> `stopSelf()`. + +Forbidden: +- Standalone `MediaPlayer`, `AudioTrack`, or raw `ExoPlayer` background playback without a `MediaSession` at target 37. +- `requestAudioFocus()` from a service that has no `MediaSession` while audio is active. The call returns `AUDIOFOCUS_REQUEST_FAILED` at target 37 with no exception. +- Holding a manual `PowerManager.WakeLock` alongside `MediaSessionService`. [android-performance.md → Excessive partial wake locks](/references/android-performance.md#excessive-partial-wake-locks-play-vitals-core-metric). + +### Manifest + +```xml + + + + + + + + + + +``` + +### Service skeleton + +```kotlin +@OptIn(UnstableApi::class) +class PlaybackService : MediaSessionService() { + + private var mediaSession: MediaSession? = null + + private val playerListener = object : Player.Listener { + override fun onPlaybackStateChanged(state: Int) { + if (state == Player.STATE_ENDED) stopSelf() + } + } + + override fun onCreate() { + super.onCreate() + val player = ExoPlayer.Builder(this).build().apply { + addListener(playerListener) + } + mediaSession = MediaSession.Builder(this, player).build() + } + + override fun onGetSession( + controllerInfo: MediaSession.ControllerInfo, + ): MediaSession? = mediaSession + + override fun onDestroy() { + mediaSession?.run { + player.removeListener(playerListener) + player.release() + release() + } + mediaSession = null + super.onDestroy() + } +} +``` diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-navigation.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-navigation.md new file mode 100644 index 000000000..5fc3a7a9e --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-navigation.md @@ -0,0 +1,2148 @@ +# Navigation + +Required: Navigation 3 with type-safe `@Serializable` `NavKey` destinations, feature-defined `Navigator` interfaces, app-module wiring. Kotlin code must align with [kotlin-patterns.md](/references/kotlin-patterns.md). Versions live in `assets/libs.versions.toml.template` (`navigation3` bundle). + +Navigation 3 1.0 is stable; pin a current version from [Navigation 3 releases](https://developer.android.com/jetpack/androidx/releases/navigation3). Reference the [nav3-recipes](https://github.com/android/nav3-recipes) repository for advanced patterns (multi-back-stack, Hilt integration, ...). + +## Table of Contents +1. [Navigation3 Architecture](#navigation3-architecture) +2. [Quick Start](#navigation-3-quick-start) +3. [App Navigation Setup](#app-navigation-setup) +4. [Navigation State Management](#navigation-3-state-management) +5. [Navigation invariants](#navigation-invariants) +6. [Navigation Flow](#navigation-flow) +7. [Migration](#migration) +8. [Animations](#animations) +9. [Scenes & Custom Layouts](#scenes--custom-layouts) +10. [Deep Links](#deep-links) +11. [Conditional Navigation](#conditional-navigation) +12. [Returning Results](#returning-results) +13. [ViewModel Scoping](#viewmodel-scoping) +14. [Adaptive Quality and Large Screens](#adaptive-quality-and-large-screens) + +## Navigation3 Architecture + +Feature-level navigation components (`AuthDestination`, `AuthNavigator`, `AuthGraph`) are created as part of the feature module setup in [modularization.md → Create Feature Module → Step 4](/references/modularization.md). + +Required: +- Each feature owns its `Destination` sealed interface (implements `NavKey`, `@Serializable`) and a `Navigator` interface. +- App module owns the back stack, implements every feature's `Navigator`, and registers entries in a single `NavDisplay`. +- Top-level chrome uses `NavigationSuiteScaffold` so bar/rail/drawer tracks window size automatically. +- Multi-pane layouts use `NavigableListDetailPaneScaffold` / `NavigableSupportingPaneScaffold` from Material 3 Adaptive - never hand-rolled width branching. +- Predictive back is on by default (required on API 36). + +## Adaptive Quality and Large Screens + +`NavigationSuiteScaffold` and pane scaffolds decide *where* navigation chrome lives; the [Adaptive app guidance](https://developer.android.com/large-screens) defines *how complete* the experience is per form factor. + +### Quality tiers + +Required floor: tier 3 on every build. Target tier 2 for productivity and tablet-heavy audiences. Target tier 1 only when foldables, Chromebooks, or stylus-first workflows are first-class. + +| Tier | Required behaviour | +|---------------------------------|--------------------------------------------------------------------------------------------------| +| **3 - Adaptive ready** | No letterboxing, handles rotation and resizing, split-screen works, basic keyboard/mouse | +| **2 - Adaptive optimized** | Responsive layouts at all widths, stronger keyboard shortcuts and hover, state survives resize | +| **1 - Adaptive differentiated** | Multitasking (drag and drop where relevant), fold postures, stylus, desktop-style windowing | + +### Width and layout (with Navigation3) + +| Window width | Typical layout (Material adaptive) | +|------------------------|------------------------------------------------------| +| Compact (under 600 dp) | Bottom bar, single pane | +| Medium (600-840 dp) | Navigation rail; add list-detail when content needs split panes | +| Expanded (over 840 dp) | Rail or persistent drawer, list-detail or multi-pane | + +Use `WindowSizeClass` / `currentWindowAdaptiveInfo()` for custom splits; use `NavigationSuiteScaffold` so bar vs rail vs drawer tracks size without manual branching. + +### Configuration and state + +Handle **configuration changes** without losing user context: rotation, fold/unfold, multi-window resize, split-screen enter/exit, hardware keyboard attach/detach. + +- Keep UI state in **ViewModel** and process death in **SavedStateHandle** (see [compose-patterns.md](/references/compose-patterns.md) and modularization docs). +- Test with **Don't keep activities** during development to flush out lost state. + +### Foldables + +| Posture | Notes for UI | +|-----------------------------------------|---------------------------------------------------------------| +| Flat / open | Treat like tablet or large phone | +| Tabletop / half-open (horizontal hinge) | Avoid primary actions on the hinge; split content per segment | +| Book / vertical hinge | Same: no critical tap targets on the fold | +| Folded closed | Single outer display; navigation matches compact patterns | + +Use Jetpack **WindowManager** (`androidx.window`) when you need explicit fold or posture; not for everyday bar vs rail decisions. + +### Pointer, keyboard, and desktop expectations + +| Input | Expectation | +|------------------|-------------------------------------------------------------------------------------------| +| Keyboard | Tab order matches visual order; Enter/Space activate; arrow keys in lists | +| Mouse / trackpad | Hover states on clickable rows; scroll wheels work; context menus where users expect them | +| Stylus | Pressure/tilt only if you draw; otherwise ignore safely | + +Large screens are often **not** touch-only. Do not rely on swipe-only shortcuts without a visible alternative. + +### Multi-window + +Assume the app **does not own the full display**. Support minimum resize width (on the order of ~220 dp per platform guidance), preserve state across bounds changes, and avoid modal flows that break when the window is half width. + +### Testing matrix (manual) + +| Scenario | Priority | +|-----------------------------------|-----------------------------------| +| Phone portrait and landscape | Required | +| Tablet portrait and landscape | Required if you ship large-screen | +| Foldable fold/unfold | High if you target foldables | +| Desktop / Chromebook windowed | Medium for those form factors | +| Split-screen and free-form resize | Required for tier 2+ | + +## Navigation 3 Quick Start + +Navigation 3 uses type-safe data classes as navigation keys. Minimal wiring: + +#### 1. Define Destinations (Feature Module) + +```kotlin +// feature/products/navigation/ProductsDestination.kt +import kotlinx.serialization.Serializable +import androidx.navigation3.runtime.NavKey + +@Serializable +sealed interface ProductsDestination : NavKey { + @Serializable + data class ProductsList(val categoryId: String) : ProductsDestination + + @Serializable + data class ProductDetail(val productId: String) : ProductsDestination +} +``` + +#### 2. Define Navigator Interface (Feature Module) + +```kotlin +// feature/products/navigation/ProductsNavigator.kt +interface ProductsNavigator { + fun navigateToDetail(productId: String) + fun navigateBack() +} +``` + +#### 3. Use in Route Composable (Feature Module) + +```kotlin +// feature/products/presentation/ProductsRoute.kt +@Composable +fun ProductsRoute( + categoryId: String, + navigator: ProductsNavigator, + viewModel: ProductsViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + ProductsListScreen( + state = uiState, + onProductClick = { productId -> + navigator.navigateToDetail(productId) + }, + onBackClick = navigator::navigateBack + ) +} +``` + +#### 4. Register in App Module + +```kotlin +// app/navigation/AppNavigation.kt +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay + +@Composable +fun AppNavigation() { + val backStack = rememberNavBackStack( + startDestination = ProductsDestination.ProductsList(categoryId = "all") + ) + + // Implement navigator + val productsNavigator = remember { + object : ProductsNavigator { + override fun navigateToDetail(productId: String) { + backStack.add(ProductsDestination.ProductDetail(productId)) + } + override fun navigateBack() { + backStack.removeLastOrNull() + } + } + } + + // Define routes + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryProvider = entryProvider { + entry { key -> + ProductsRoute( + categoryId = key.categoryId, + navigator = productsNavigator + ) + } + entry { key -> + ProductDetailRoute( + productId = key.productId, + navigator = productsNavigator + ) + } + } + ) +} +``` + +**Key Points:** +- Routes are `@Serializable` data classes (type-safe, saved across process death) +- Feature modules define `Navigator` interfaces (no navigation logic) +- App module implements `Navigator` and registers all routes +- Use `rememberNavBackStack()` for simple navigation or `rememberNavigationState()` for multi-stack (bottom nav) + +## App Navigation Setup + +```kotlin +// app/src/main/kotlin/com/example/app/navigation/AppNavigation.kt +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay +import kotlinx.serialization.Serializable + +@Immutable +sealed interface TopLevelRoute : NavKey { + @Serializable data object Auth : TopLevelRoute + @Serializable data object Profile : TopLevelRoute + @Serializable data object Settings : TopLevelRoute +} + +@Composable +fun AppNavigation( + analytics: Analytics +) { + // Create navigation state (survives config changes and process death) + val navigationState = rememberNavigationState( + startRoute = TopLevelRoute.Auth, + topLevelRoutes = setOf( + TopLevelRoute.Auth, + TopLevelRoute.Profile, + TopLevelRoute.Settings + ) + ) + + val navigator = remember(navigationState) { Navigator(navigationState) } + + // Track screen views for analytics/crashlytics + LaunchedEffect(navigationState.topLevelRoute) { + val currentStack = navigationState.backStacks[navigationState.topLevelRoute] + val currentRoute = currentStack?.last() + currentRoute?.let { route -> + analytics.logScreenView( + screenName = route::class.simpleName ?: "Unknown", + screenClass = "MainActivity" + ) + } + } + + // Create navigator implementations + val authNavigator = remember(navigator) { + object : AuthNavigator { + override fun navigateToRegister() = navigator.navigate(AuthDestination.Register) + override fun navigateToForgotPassword() = navigator.navigate(AuthDestination.ForgotPassword) + override fun navigateBack() = navigator.goBack() + override fun navigateToProfile(userId: String) = navigator.navigate(AuthDestination.Profile(userId)) + override fun navigateToMainApp() = navigator.navigate(TopLevelRoute.Profile) + } + } + + // Define all app destinations + val entryProvider = entryProvider { + authGraph(authNavigator) + profileGraph() + settingsGraph() + } + + // NavigationSuiteScaffold auto-switches between bar/rail/drawer based on window size + NavigationSuiteScaffold( + navigationSuiteItems = { + item( + icon = { Icon(painterResource(R.drawable.ic_lock), contentDescription = null) }, + label = { Text("Auth") }, + selected = navigationState.topLevelRoute == TopLevelRoute.Auth, + onClick = { navigator.navigate(TopLevelRoute.Auth) } + ) + item( + icon = { Icon(painterResource(R.drawable.ic_person), contentDescription = null) }, + label = { Text("Profile") }, + selected = navigationState.topLevelRoute == TopLevelRoute.Profile, + onClick = { navigator.navigate(TopLevelRoute.Profile) } + ) + item( + icon = { Icon(painterResource(R.drawable.ic_settings), contentDescription = null) }, + label = { Text("Settings") }, + selected = navigationState.topLevelRoute == TopLevelRoute.Settings, + onClick = { navigator.navigate(TopLevelRoute.Settings) } + ) + } + ) { + NavDisplay( + entries = navigationState.toEntries(entryProvider), + onBack = { navigator.goBack() }, + modifier = Modifier.fillMaxSize() + ) + } +} +``` + +**Icon Resources**: See `references/android-graphics.md` for complete guidance on: +- Material Symbols icons (download via Iconify API or Google Fonts) +- ImageVector patterns for programmatic icons +- Custom drawing with Canvas +- Performance optimizations + +**Quick example:** +```kotlin +// Download icon +curl -o app/src/main/res/drawable/ic_lock.xml \ + "https://api.iconify.design/material-symbols:lock.svg?download=true" + +// Usage +Icon( + painter = painterResource(R.drawable.ic_lock), + contentDescription = stringResource(R.string.lock_icon) +) +``` + +**Analytics Integration**: Inject `Analytics` interface (from `references/crashlytics.md`) instead of using Firebase directly. This provides abstraction for crash reporting and analytics. + +## Navigation 3 State Management + +Navigation 3 uses explicit state management with Unidirectional Data Flow: + +**1. NavigationState** - Holds current route and back stacks: +```kotlin +// Copy this into NavigationState.kt in your app module +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 + +@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 + ) + } +} + +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) + } +} + +@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() +} +``` + +**2. Navigator** - Modifies navigation state: +```kotlin +// Copy this into Navigator.kt in your app module +import androidx.navigation3.runtime.NavKey + +class Navigator(val state: NavigationState) { + fun navigate(route: NavKey) { + if (route in state.backStacks.keys) { + // Top-level route: swap the active tab instead of pushing a child. + 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() + } + } +} +``` + +**3. Feature Navigator Interface**: +```kotlin +// feature-auth/navigation/AuthNavigator.kt +interface AuthNavigator { + fun navigateToRegister() + fun navigateToForgotPassword() + fun navigateBack() + fun navigateToProfile(userId: String) + fun navigateToMainApp() +} + +// In App module implementation: +val authNavigator = remember(navigator) { + object : AuthNavigator { + override fun navigateToRegister() = navigator.navigate(AuthDestination.Register) + override fun navigateToForgotPassword() = navigator.navigate(AuthDestination.ForgotPassword) + override fun navigateBack() = navigator.goBack() + override fun navigateToProfile(userId: String) = navigator.navigate(AuthDestination.Profile(userId)) + override fun navigateToMainApp() = navigator.navigate(TopLevelRoute.Profile) + } +} +``` + +**Architecture principles:** These classes follow Unidirectional Data Flow: +- The `Navigator` handles navigation events and updates `NavigationState` +- The UI (provided by `NavDisplay`) observes `NavigationState` and reacts to changes + +## Navigation invariants + +1. **Feature Independence**: Features define `Navigator` interfaces +2. **Central Coordination**: App module implements all navigators +3. **Type-Safe Routes**: Routes implement `NavKey` with `@Serializable` and `@Immutable` +4. **Explicit State Management**: `NavigationState` + `Navigator` manage navigation state +5. **Adaptive Navigation**: `NavigationSuiteScaffold` auto-switches between bar/rail/drawer based on window size + +## Navigation Flow + +End-to-end flow diagrams (UI → data → navigation): [architecture.md](/references/architecture.md). + +## Migration + +Navigation 2.x → Navigation3: [migration.md → Navigation 2.x to Navigation3](/references/migration.md#navigation-2x-to-navigation3). + +## Animations + +`NavDisplay` provides built-in animation support via `ContentTransform`. Customize globally or per-entry. + +### Global Transitions + +Set default animations for all destinations on `NavDisplay`: + +```kotlin +NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + transitionSpec = { + // Forward navigation: slide in from right + slideInHorizontally(initialOffsetX = { it }) togetherWith + slideOutHorizontally(targetOffsetX = { -it }) + }, + popTransitionSpec = { + // Back navigation: slide in from left + slideInHorizontally(initialOffsetX = { -it }) togetherWith + slideOutHorizontally(targetOffsetX = { it }) + }, + predictivePopTransitionSpec = { + // Predictive back gesture: same as popTransitionSpec + slideInHorizontally(initialOffsetX = { -it }) togetherWith + slideOutHorizontally(targetOffsetX = { it }) + }, + entryProvider = entryProvider { + // ... + } +) +``` + +**Parameters:** +- `transitionSpec` - `ContentTransform` when content is added to back stack (navigating forward) +- `popTransitionSpec` - `ContentTransform` when content is removed from back stack (navigating back) +- `predictivePopTransitionSpec` - `ContentTransform` during predictive back gestures (Android 14+) + +### Per-Entry Overrides + +Override global transitions for specific entries using metadata helper functions: + +```kotlin +entry( + metadata = NavDisplay.transitionSpec { + // Slide up from bottom, keep old content underneath + slideInVertically( + initialOffsetY = { it }, + animationSpec = tween(1000) + ) togetherWith ExitTransition.KeepUntilTransitionsFinished + } + NavDisplay.popTransitionSpec { + // Slide down, reveal content underneath + EnterTransition.None togetherWith + slideOutVertically( + targetOffsetY = { it }, + animationSpec = tween(1000) + ) + } + NavDisplay.predictivePopTransitionSpec { + EnterTransition.None togetherWith + slideOutVertically( + targetOffsetY = { it }, + animationSpec = tween(1000) + ) + } +) { + ScreenCContent() +} +``` + +**Metadata keys** (combine with `+`): +- `NavDisplay.transitionSpec { ... }` - forward animation for this entry +- `NavDisplay.popTransitionSpec { ... }` - back animation for this entry +- `NavDisplay.predictivePopTransitionSpec { ... }` - predictive back animation for this entry + +Per-entry metadata overrides the global `NavDisplay` transitions. + +### Common Animation Patterns + +```kotlin +// Fade +fadeIn(tween(300)) togetherWith fadeOut(tween(300)) + +// Horizontal slide +slideInHorizontally(initialOffsetX = { it }) togetherWith + slideOutHorizontally(targetOffsetX = { -it }) + +// Vertical slide (bottom sheet style) +slideInVertically(initialOffsetY = { it }) togetherWith + ExitTransition.KeepUntilTransitionsFinished + +// No animation +EnterTransition.None togetherWith ExitTransition.None +``` + +## Scenes & Custom Layouts + +A `Scene` is the fundamental rendering unit in Navigation 3. It renders one or more `NavEntry` instances, allowing single-pane, multi-pane, dialog, and bottom sheet layouts. A `SceneStrategy` determines how back stack entries are arranged into a `Scene`. + +### Scene Interface + +```kotlin +interface Scene { + val key: Any + val entries: List> + val previousEntries: List> + val content: @Composable () -> Unit +} +``` + +- `key` - unique identifier driving top-level animation when the Scene changes +- `entries` - the `NavEntry` objects this Scene displays +- `previousEntries` - entries for calculating predictive back state +- `content` - composable rendering the Scene's entries + +### SceneStrategy + +A `SceneStrategy` decides whether it can create a `Scene` from the current back stack entries: + +```kotlin +interface SceneStrategy { + fun SceneStrategyScope.calculateScene( + entries: List> + ): Scene? +} +``` + +Returns `null` if it cannot handle the entries, letting the next strategy try. Built-in strategies: +- `SinglePaneSceneStrategy` - displays the last entry full-screen (default) +- `DialogSceneStrategy` - renders entries marked as dialogs in an overlay + +### Dialog Navigation + +Use `DialogSceneStrategy` to show entries as dialogs: + +```kotlin +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.scene.DialogSceneStrategy +import androidx.navigation3.ui.NavDisplay + +@Composable +fun DialogExample() { + val backStack = rememberNavBackStack(HomeRoute) + val dialogStrategy = remember { DialogSceneStrategy() } + + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + sceneStrategy = dialogStrategy, + entryProvider = entryProvider { + entry { + HomeScreen( + onShowDialog = dropUnlessResumed { + backStack.add(ConfirmRoute("Are you sure?")) + } + ) + } + entry( + metadata = DialogSceneStrategy.dialog( + DialogProperties(dismissOnClickOutside = true) + ) + ) { key -> + ConfirmDialog( + message = key.message, + onDismiss = { backStack.removeLastOrNull() } + ) + } + } + ) +} +``` + +**Required:** +- Pass `DialogSceneStrategy()` as `sceneStrategy` to `NavDisplay`. +- Mark dialog entries with `metadata = DialogSceneStrategy.dialog(DialogProperties(...))`. +- Dialog entries render as overlays above the previous entry. +- Wrap navigations that open dialogs in `dropUnlessResumed` to block double taps during transitions. + +### Bottom Sheet Navigation + +Navigation 3 ships no first-party `BottomSheetSceneStrategy`. Use the custom strategy below: it renders the top entry inside a Material 3 `ModalBottomSheet` and keeps the previous entry visible underneath. + +```kotlin +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.scene.Scene +import androidx.navigation3.scene.SceneStrategy +import androidx.navigation3.scene.SinglePaneSceneStrategy + +private const val BOTTOM_SHEET_KEY = "BottomSheetSceneStrategy" + +class BottomSheetSceneStrategy( + private val onDismiss: () -> Unit, +) : SceneStrategy { + + override fun SceneStrategyScope.calculateScene( + entries: List>, + ): Scene? { + val top = entries.lastOrNull() ?: return null + if (top.metadata[BOTTOM_SHEET_KEY] != true) return null + + val previous = entries.dropLast(1) + return object : Scene { + override val key: Any = top.contentKey + override val entries: List> = listOf(top) + override val previousEntries: List> = previous + override val content: @Composable () -> Unit = { + previous.lastOrNull()?.Content() + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + top.Content() + } + } + } + } + + companion object { + fun bottomSheet(): Map = mapOf(BOTTOM_SHEET_KEY to true) + } +} + +@Composable +fun BottomSheetExample() { + val backStack = rememberNavBackStack(HomeRoute) + val bottomSheetStrategy = remember { + BottomSheetSceneStrategy(onDismiss = { backStack.removeLastOrNull() }) + } + + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + sceneStrategy = bottomSheetStrategy, + entryProvider = entryProvider { + entry { + HomeScreen( + onShowFilters = dropUnlessResumed { backStack.add(FiltersRoute) } + ) + } + entry( + metadata = BottomSheetSceneStrategy.bottomSheet() + ) { + FiltersBottomSheet( + onApply = { backStack.removeLastOrNull() } + ) + } + } + ) +} +``` + +**Required:** +- Mark sheet entries with `metadata = BottomSheetSceneStrategy.bottomSheet()`; unmarked entries keep `SinglePaneSceneStrategy`. +- Bind `onDismissRequest` to `backStack.removeLastOrNull()` so scrim and swipe-dismiss stay stack-driven — no parallel boolean dismiss flags. +- Predictive back follows the back stack without extra glue. + +### Custom Scene: List-Detail Layout + +Create a custom `Scene` and `SceneStrategy` for adaptive layouts (e.g., list-detail on wide screens): + +```kotlin +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.scene.Scene +import androidx.navigation3.scene.SceneStrategy +import androidx.window.core.layout.WIDTH_DP_MEDIUM_LOWER_BOUND +import androidx.window.core.layout.WindowSizeClass + +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() + } + Column(modifier = Modifier.weight(0.6f)) { + detailEntry.Content() + } + } + } +} + +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.containsKey(DETAIL_KEY) } ?: return null + val listEntry = entries.findLast { + it.metadata.containsKey(LIST_KEY) + } ?: return null + + return ListDetailScene( + key = listEntry.contentKey, + previousEntries = entries.dropLast(1), + listEntry = listEntry, + detailEntry = detailEntry + ) + } + + companion object { + internal const val LIST_KEY = "ListDetailScene-List" + internal const val DETAIL_KEY = "ListDetailScene-Detail" + + fun listPane() = mapOf(LIST_KEY to true) + fun detailPane() = mapOf(DETAIL_KEY to true) + } +} + +@Composable +fun rememberListDetailSceneStrategy(): ListDetailSceneStrategy { + val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass + return remember(windowSizeClass) { ListDetailSceneStrategy(windowSizeClass) } +} +``` + +**Usage:** +```kotlin +val listDetailStrategy = rememberListDetailSceneStrategy() + +NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + sceneStrategy = listDetailStrategy, + entryProvider = entryProvider { + entry( + metadata = ListDetailSceneStrategy.listPane() + ) { + ConversationListScreen(onSelect = { id -> + backStack.removeIf { it is ConversationDetail } + backStack.add(ConversationDetail(id)) + }) + } + entry( + metadata = ListDetailSceneStrategy.detailPane() + ) { key -> + ConversationDetailScreen(conversationId = key.id) + } + } +) +``` + +On wide screens, list and detail show side-by-side (40/60 split). On narrow screens, the strategy returns `null` and the default `SinglePaneSceneStrategy` takes over. + +### Material3 Adaptive Scenes + +For production list-detail and supporting-pane layouts, use the pre-built Material3 Adaptive scenes from `androidx.compose.material3.adaptive:adaptive-navigation3`: + +```kotlin +import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy +import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun MaterialListDetailExample() { + val backStack = rememberNavBackStack(ProductList) + val listDetailStrategy = rememberListDetailSceneStrategy() + + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + sceneStrategy = listDetailStrategy, + entryProvider = entryProvider { + entry( + metadata = ListDetailSceneStrategy.listPane( + detailPlaceholder = { + Text("Select a product from the list") + } + ) + ) { + ProductListScreen(onProductClick = { id -> + backStack.add(ProductDetail(id)) + }) + } + entry( + metadata = ListDetailSceneStrategy.detailPane() + ) { key -> + ProductDetailScreen(productId = key.id) + } + entry( + metadata = ListDetailSceneStrategy.extraPane() + ) { + ProductProfileScreen() + } + } + ) +} +``` + +**Material3 metadata helpers:** +- `ListDetailSceneStrategy.listPane(detailPlaceholder = { ... })` — marks the list pane; supply `detailPlaceholder` when the detail pane can be empty +- `ListDetailSceneStrategy.detailPane()` - marks entry as detail pane +- `ListDetailSceneStrategy.extraPane()` - marks entry as extra pane (three-pane layout) + +The Material3 `ListDetailSceneStrategy` automatically handles pane arrangement, predictive back, and window size adaptation. For supporting-pane layouts, use `rememberSupportingPaneSceneStrategy()` with matching metadata. + +## Deep Links + +Required: parse `Intent.data` into a `NavKey`, push the result onto the back stack, and keep Up/Back aligned with [Principles of Navigation](https://developer.android.com/guide/navigation/principles). + +### Parsing an Intent into a NavKey + +Required: decode the incoming `Intent` data URI with `kotlinx.serialization` and the Navigation 3 `DeepLinkPattern` / `KeyDecoder` pipeline. + +Required: declare every supported URI in `deepLinkPatterns`: +```kotlin +// app/deeplink/DeepLinkPatterns.kt +import androidx.navigation3.runtime.NavKey + +internal val deepLinkPatterns: List> = listOf( + DeepLinkPattern( + serializer = HomeRoute.serializer(), + pattern = "https://example.com/home".toUri() + ), + DeepLinkPattern( + serializer = ProductDetail.serializer(), + pattern = "https://example.com/products/{productId}".toUri() + ), + DeepLinkPattern( + serializer = UserProfile.serializer(), + pattern = "https://example.com/users/{userId}".toUri() + ), +) +``` + +Required: parse in `Activity.onCreate` (or the shared entry used by `onCreate` and `onNewIntent`): +```kotlin +// app/MainActivity.kt +override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val deepLinkKey: NavKey = intent.data?.let { uri -> + val request = DeepLinkRequest(uri) + + val match = deepLinkPatterns.firstNotNullOfOrNull { pattern -> + DeepLinkMatcher(request, pattern).match() + } + + match?.let { + KeyDecoder(match.args).decodeSerializableValue(match.serializer) + } + } ?: HomeRoute + + setContent { + val backStack = rememberNavBackStack(deepLinkKey) + // ... NavDisplay setup + } +} +``` + +Required roles per parse: + +- `DeepLinkPattern` maps a URI pattern to a `NavKey` serializer; `{path}` and `?query` placeholders bind to `@Serializable` fields. +- `DeepLinkRequest` materialises path segments and query parameters for matching. +- `DeepLinkMatcher` selects the first matching pattern. +- `KeyDecoder` decodes matched arguments into the concrete `NavKey`. + +### Synthetic Back Stack + +Required on the new-task deep-link path: build a synthetic back stack so Up/Back walks parent screens instead of exiting after one pop. + +Required: model `DeepLinkKey.parent` for every deep-linked destination: +```kotlin +interface DeepLinkKey : NavKey { + val parent: NavKey +} + +@Serializable +data object HomeRoute : NavKey + +@Serializable +data object ProductListRoute : DeepLinkKey { + override val parent: NavKey = HomeRoute +} + +@Serializable +data class ProductDetail(val productId: String) : DeepLinkKey { + override val parent: NavKey = ProductListRoute +} +``` + +Required: walk `DeepLinkKey.parent` from the leaf key to the root to build the list: +```kotlin +fun buildSyntheticBackStack(deepLinkKey: NavKey): List = buildList { + var current: NavKey? = deepLinkKey + while (current != null) { + add(0, current) + current = (current as? DeepLinkKey)?.parent + } +} +``` + +Required: pass the synthetic list into `rememberNavBackStack` before `NavDisplay`: +```kotlin +val syntheticBackStack = buildSyntheticBackStack(deepLinkKey) + +setContent { + val backStack = rememberNavBackStack(*syntheticBackStack.toTypedArray()) + + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryProvider = entryProvider { /* ... */ } + ) +} +``` + +Required stack shape for `ProductDetail("abc")`: `[HomeRoute, ProductListRoute, ProductDetail("abc")]`; Back pops in reverse order. + +### Task Management + +Required: branch on `Intent.FLAG_ACTIVITY_NEW_TASK` - new task vs existing task changes whether a synthetic stack is mandatory vs optional. + +Required: read `intent.flags` in `onCreate` before branching: +```kotlin +override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val isNewTask = intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK != 0 + val deepLinkKey = parseDeepLink(intent) + + if (isNewTask) { + val syntheticBackStack = buildSyntheticBackStack(deepLinkKey) + // CORRECT: new task - seed stack with syntheticBackStack before NavDisplay. + } else { + // CORRECT: existing task - append deepLinkKey to the live stack (or replace per app policy). + } +} +``` + +Required on the original task: restart the Activity in a new task so Up stays inside the app: +```kotlin +fun navigateUp(deepLinkKey: NavKey, activity: Activity) { + val parentKey = (deepLinkKey as? DeepLinkKey)?.parent + + val intent = Intent(activity, activity::class.java).apply { + if (parentKey is DeepLinkKey) { + data = parentKey.toDeepLinkUri() + } + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + } + + TaskStackBuilder.create(activity) + .addNextIntentWithParentStack(intent) + .startActivities() + activity.finish() +} +``` + +| Scenario | Back | Up | Synthetic back stack? | +|---------------|---------------------|--------------------------------------|---------------------------| +| New task | Parent screen | Parent screen | Yes, on Activity creation | +| Existing task | Previous app/screen | Parent screen (restarts in new task) | Optional | + +Forbidden: show Up on the start destination - no in-app parent exists. + +Forbidden: route Up out of the app - Up targets only in-app parents (including synthetic-stack parents). + +Required: synthetic stack models the manual path from the root destination to the deep-linked key. + +### AndroidManifest Setup + +Required on the deep-link `Activity`: `android:exported="true"` (mandatory on Android 12+ for any Activity with an intent-filter), `android:launchMode="singleTask"` so re-entering the app reuses the existing Activity via `onNewIntent` (see [onNewIntent for singleTask](#onnewintent-for-singletask)). + +Required: keep HTTPS App Links and custom schemes in **separate** `` blocks. `android:autoVerify="true"` only works on the HTTPS filter and verifies every `` host inside that single filter. + +```xml + + + + + + + + + + + + + + + + + + + + + + +``` + +`` matching rules: + +| Attribute | Use when | +|-------------------------------|--------------------------------------------------------------------------------------| +| `android:scheme` | Required first. Declare once per filter (`https` for App Links). | +| `android:host` | Declare once per host. List every host in the same `autoVerify` filter. | +| `android:pathPrefix` | Default. Matches `/products`, `/products/123`, `/products/anything`. | +| `android:pathSuffix` | Use when the dynamic segment is the prefix (`/share/abc.png`, suffix `.png`). | +| `android:path` | Use for an exact-match URL with no parameters (`/about`). | +| `android:pathPattern` | Use when prefix/suffix cannot express the rule. `.*` = any chars, `\\*` = literal *. | +| `android:pathAdvancedPattern` | Use for full regex (`[a-z]{2,4}/.*`) on API 31+. Falls back to no-match below 31. | + +Required: every `` host inside an `autoVerify` filter must be served by a Digital Asset Links file (see [App Links Verification](#app-links-verification)). On Android 11 and lower, **one** unverifiable host fails verification for **all** hosts in that filter. + +Forbidden: `android:autoVerify="true"` on a custom-scheme filter. App Links verification is HTTPS-only; the attribute is silently ignored on other schemes (see [Custom-Scheme Deep Linking](#custom-scheme-deep-linking)). + +Forbidden: combining `` and `` in one filter - every scheme/host pair becomes a verification target and the non-https schemes break `autoVerify`. + +Required: keep `pathPrefix` entries narrow. Forbidden: `pathPrefix="/"` on production builds - claims every URL on the host and the system rejects the verification batch. + +### onNewIntent for singleTask + +Required when `android:launchMode="singleTask"`: implement `onNewIntent` so a deep link delivered to an already-running Activity updates the back stack instead of being dropped on the floor. + +Required: route both the `onCreate` initial intent and every subsequent `onNewIntent` through the same `parseDeepLink` function so behaviour stays consistent. + +```kotlin +// app/MainActivity.kt +class MainActivity : ComponentActivity() { + + private val pendingDeepLink = mutableStateOf(null) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val initialKey = parseDeepLink(intent) ?: HomeRoute + + setContent { + val backStack = rememberNavBackStack(initialKey) + + LaunchedEffect(Unit) { + snapshotFlow { pendingDeepLink.value } + .filterNotNull() + .collect { key -> + backStack.add(key) + pendingDeepLink.value = null + } + } + + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryProvider = entryProvider { /* ... */ } + ) + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + // CORRECT: setIntent so any later getIntent() read sees the new URI, not the original. + setIntent(intent) + parseDeepLink(intent)?.let { pendingDeepLink.value = it } + } + + private fun parseDeepLink(intent: Intent): NavKey? { + val uri = intent.data ?: return null + if (!DeepLinkValidator.validate(uri)) return null + val request = DeepLinkRequest(uri) + val match = deepLinkPatterns.firstNotNullOfOrNull { pattern -> + DeepLinkMatcher(request, pattern).match() + } ?: return null + return KeyDecoder(match.args).decodeSerializableValue(match.serializer) + } +} +``` + +Forbidden: reading `intent.data` directly inside Composables - `intent` does not change reference when `onNewIntent` fires; route the new URI through state (`mutableStateOf`, `Channel`, `SharedFlow`). + +Forbidden: omitting `setIntent(intent)` in `onNewIntent` - leaves stale `getIntent()` results for any later code path (notification action handlers, restored process death). + +Use when: `Intent.FLAG_ACTIVITY_NEW_TASK` is set - seed the stack from [Synthetic Back Stack](#synthetic-back-stack) before the first frame. + +Use when: the Activity stays in the existing task - append the parsed key to the live back stack (or replace the stack per app policy). + +### App Links Verification + +Required for HTTPS deep links: publish a Digital Asset Links file (`assetlinks.json`) on every host declared in the `autoVerify` intent-filter. + +Forbidden: ship `autoVerify` hosts without a reachable `assetlinks.json` - opens the browser or the disambiguation dialog. + +#### Server contract + +Required, all of: + +| Rule | Value | +|------------------|-------------------------------------------------------------------------------------------------| +| URL | `https:///.well-known/assetlinks.json` (exact path) | +| Scheme | HTTPS only. HTTP is rejected. | +| Status | HTTP 200. **Any redirect fails verification.** | +| Content-Type | `application/json` | +| Auth | None. No cookies, no Basic auth, no IP allowlist. | +| Apex consistency | `https://example.com.` (with trailing dot) must serve identical bytes to `https://example.com`. | + +Forbidden redirects: `http://example.com` → `https://example.com`, `example.com` → `www.example.com`. Both kill verification for the entire app on Android 12+. + +#### `assetlinks.json` template + +```json +[ + { + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "com.example.app", + "sha256_cert_fingerprints": [ + "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99", + "11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00" + ] + } + } +] +``` + +Required: every fingerprint string is **uppercase**, colon-separated SHA-256. Lowercase fingerprints fail silently. + +Required fingerprints: include every certificate that signs an APK that ships to a real device - Play-managed signing key, upload key (only when not enrolled in Play App Signing), debug key (for QA tracks). + +#### Where to get the SHA-256 + +Use Play App Signing when enrolled - local `keytool` output is **not** the runtime fingerprint: + +| App-signing setup | Source of the SHA-256 | +|-----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------| +| Play App Signing (default for new apps) | Play Console → Release → Setup → App signing → "App signing key certificate". Copy the SHA-256. Console also exposes the upload-key SHA-256. | +| Self-managed release keystore | `keytool -list -v -keystore release.jks -alias `. Copy the `SHA256:` line. | +| Debug builds | `keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android`. | + +Forbidden: shipping only the upload-key SHA-256 when Play App Signing is enrolled - installs from the Play Store carry the Play-managed signature, not the upload signature, and verification fails on every Play install. + +#### Multi-app per domain + +Required when several apps share a host (separate consumer + B2B builds, vendor split): one statement file with multiple `target` blocks. Different apps may handle different path prefixes via their own intent-filters. + +```json +[ + { + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "com.example.consumer", + "sha256_cert_fingerprints": ["AA:BB:..."] + } + }, + { + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "com.example.b2b", + "sha256_cert_fingerprints": ["CC:DD:..."] + } + } +] +``` + +#### Multi-domain per app + +Required when one app handles several hosts: publish an identical `assetlinks.json` at each `https:///.well-known/assetlinks.json` and list every host in the same `autoVerify` intent-filter (see [AndroidManifest Setup](#androidmanifest-setup)). + +Forbidden on Android 11 and lower: declaring a host you cannot serve `assetlinks.json` for - fails verification for **every** host in that filter (all-or-nothing). + +#### Verify the file is reachable + +Required: hit the Digital Asset Links REST endpoint from CI or a laptop before blocking on-device `pm get-app-links` - no device required. + +```bash +curl 'https://digitalassetlinks.googleapis.com/v1/statements:list?\ +source.web.site=https://example.com&\ +relation=delegate_permission/common.handle_all_urls' +``` + +Required JSON shape in the response: a non-empty `statements` array containing the package name and uppercase fingerprint that match the manifest. Empty array = file unreachable, malformed, or wrong content-type. + +Per-device verification (`pm set-app-links`, `pm verify-app-links --re-verify`, `pm get-app-links`) and the return-code legend: [testing.md → Testing Deep Links](/references/testing.md#testing-deep-links). + +### Dynamic App Links (Android 15+, API 35) + +API floor: 35. Devices with Google Play services periodically refresh `assetlinks.json` and merge server-side rules with manifest filters. Older devices ignore the dynamic block. + +Required: dynamic rules can only **narrow** what the manifest declares. Set the broadest scope (scheme + host) in the manifest; refine path / query / fragment server-side. + +Forbidden: relying on dynamic rules to add a host or scheme not in the manifest. The system silently drops them. + +#### `dynamic_app_link_components` shape + +```json +[ + { + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "com.example.app", + "sha256_cert_fingerprints": ["AA:BB:..."] + }, + "relation_extensions": { + "delegate_permission/common.handle_all_urls": { + "dynamic_app_link_components": [ + {"/": "/products/*"}, + {"/": "/shoes", "?": {"in_app": "true"}}, + {"#": "app"}, + {"?": {"dl": "*"}}, + {"/": "/internal/*", "exclude": true}, + {"/": "*"} + ] + } + } + } +] +``` + +Required: each rule object may set any of these keys; every set key must match the URL: + +| Key | Type | Matches | +|-------------|---------|------------------------------------------------------------------------------------------------------------------------------------| +| `"/"` | string | URL path. Wildcards: `*` (zero or more chars), `?` (single char), `?*` (one or more chars). | +| `"#"` | string | URL fragment (after `#`). Same wildcards as path. | +| `"?"` | object | Query parameter dict. Every entry must match a `key=value` pair in the URL. Order does not matter; extra query params are allowed. | +| `"exclude"` | boolean | When `true`, matching URLs **do not** open the app. Default `false`. | + +#### Ordering rules + +Required: declare more specific rules first. Evaluation stops at the first match. + +```json +{"/": "/path1"}, +{"/": "*", "exclude": true} +``` + +Outcome for `{"/": "/path1"}` then `{"/": "*", "exclude": true}`: `/path1` opens the app; every other path is excluded. + +```json +{"/": "*", "exclude": true}, +{"/": "/path1"} +``` + +Outcome for `{"/": "*", "exclude": true}` then `{"/": "/path1"}`: no URL opens the app - the `*` exclude rule matches first for every path including `/path1`. + +#### "Exclude one path, allow the rest" + +Required pattern: exclude rule, then catch-all allow rule. Omitting the catch-all excludes every URL not matched by an earlier rule. + +```json +{"/": "/admin/*", "exclude": true}, +{"/": "*"} +``` + +Forbidden: ending the list with only excludes. Unmatched URLs default to **excluded**, breaking every host the manifest still declares. + +#### Failure modes + +Required: validate JSON server-side before publishing. Malformed `relation_extensions` or empty `dynamic_app_link_components` makes the device discard all dynamic rules and fall back to the manifest filter alone - silently. + +Required after every server-side rule change: force a re-fetch with `adb shell pm verify-app-links --re-verify com.example.app` (per-device cache; eventual consistency without it). Production devices pick up the new file on their own refresh schedule. + +Required: cross-check live rules against the Digital Asset Links REST response; append `&return_relation_extensions=true`: + +```bash +curl 'https://digitalassetlinks.googleapis.com/v1/statements:list?\ +source.web.site=https://example.com&\ +relation=delegate_permission/common.handle_all_urls&\ +return_relation_extensions=true' +``` + +Required: the REST JSON exposes `dynamic_app_link_components` under the same relation key as the published `assetlinks.json`. Empty or missing field means devices never load those rules. + +### DomainVerificationManager Runtime Check + +API floor: 31. Required guard: `Build.VERSION.SDK_INT >= Build.VERSION_CODES.S` before any `DomainVerificationManager` call. + +Use `DomainVerificationManager` when: surfacing a Settings CTA for hosts stuck in `DOMAIN_STATE_NONE`, or hiding in-app copy that assumes verified App Links when the host map says otherwise. + +```kotlin +// app/deeplink/AppLinkVerificationStatus.kt +import android.content.Context +import android.content.pm.verify.domain.DomainVerificationManager +import android.content.pm.verify.domain.DomainVerificationUserState +import android.os.Build +import androidx.annotation.RequiresApi + +data class AppLinkStatus( + val verified: List, + val userSelected: List, + val unapproved: List, +) + +@RequiresApi(Build.VERSION_CODES.S) +fun Context.appLinkStatus(): AppLinkStatus { + val manager = getSystemService(DomainVerificationManager::class.java) + val state = manager.getDomainVerificationUserState(packageName) ?: return AppLinkStatus(emptyList(), emptyList(), emptyList()) + + val grouped = state.hostToStateMap.entries.groupBy { (_, value) -> + when (value) { + DomainVerificationUserState.DOMAIN_STATE_VERIFIED -> "verified" + DomainVerificationUserState.DOMAIN_STATE_SELECTED -> "selected" + else -> "unapproved" + } + } + return AppLinkStatus( + verified = grouped["verified"].orEmpty().map { it.key }, + userSelected = grouped["selected"].orEmpty().map { it.key }, + unapproved = grouped["unapproved"].orEmpty().map { it.key }, + ) +} +``` + +Required when `unapproved` is non-empty: open `Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS` for the package. No API grants verification without user or verifier action. + +```kotlin +// app/deeplink/AppLinkSettings.kt +import android.content.Context +import android.content.Intent +import android.provider.Settings +import androidx.core.net.toUri + +fun Context.openAppLinkSettings() { + val intent = Intent( + Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS, + "package:$packageName".toUri() + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) +} +``` + +Required in Compose: gate the banner on API 31+ (`Build.VERSION.SDK_INT >= Build.VERSION_CODES.S`) before calling `appLinkStatus()`. + +```kotlin +@Composable +fun AppLinkApprovalBanner(onOpenSettings: () -> Unit) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return + + val context = LocalContext.current + val status = remember { context.appLinkStatus() } + + if (status.unapproved.isEmpty()) return + + Banner( + message = "Approve ${status.unapproved.size} link(s) in Settings", + action = "Open settings", + onAction = onOpenSettings, + ) +} +``` + +Forbidden: caching the result across process restarts - verification state changes when the user toggles defaults, when the app re-runs verification, or when Play re-installs. + +Forbidden: show the Settings CTA when every declared host is already `DOMAIN_STATE_VERIFIED` - nothing left to approve. + +Required: map `hostToStateMap` integer values using this table: + +| Constant | Meaning | +|----------------------------------|-------------------------------------------------------------------------------| +| `DOMAIN_STATE_VERIFIED` | Auto-verified via Digital Asset Links. App opens the link without a dialog. | +| `DOMAIN_STATE_SELECTED` | User manually picked this app as the default for the host in system settings. | +| `DOMAIN_STATE_NONE` | Not verified and not user-selected. Link goes to browser or disambiguation. | + +### URI Pattern Matching + +Required: register one `DeepLinkPattern` per supported URI shape; placeholders bind to `@Serializable` fields on the `NavKey`. + +```kotlin +// app/deeplink/DeepLinkPatterns.kt + +private const val BASE_URL = "https://example.com" + +internal val deepLinkPatterns: List> = listOf( + // Exact match + DeepLinkPattern( + serializer = HomeRoute.serializer(), + pattern = "$BASE_URL/home".toUri() + ), + // Path parameter: /products/{productId} + DeepLinkPattern( + serializer = ProductDetail.serializer(), + pattern = "$BASE_URL/products/{productId}".toUri() + ), + // Multiple path parameters: /orders/{orderId}/items/{itemId} + DeepLinkPattern( + serializer = OrderItemDetail.serializer(), + pattern = "$BASE_URL/orders/{orderId}/items/{itemId}".toUri() + ), + // Query parameters: /search?query={query}&category={category} + DeepLinkPattern( + serializer = SearchRoute.serializer(), + pattern = "$BASE_URL/search?query={query}&category={category}".toUri() + ), + // Custom scheme: myapp://open/profile/{userId} + DeepLinkPattern( + serializer = UserProfile.serializer(), + pattern = "myapp://open/profile/{userId}".toUri() + ), +) +``` + +`{placeholder}` names must match the `@Serializable` field names in the corresponding `NavKey`: +```kotlin +@Serializable +data class OrderItemDetail(val orderId: String, val itemId: String) : NavKey +``` + +### Deep Link Security + +Required: treat every deep link as untrusted input - validate, allowlist, then navigate. + +```kotlin +// app/deeplink/DeepLinkValidator.kt +object DeepLinkValidator { + + private val ALLOWED_HOSTS = setOf("example.com", "www.example.com") + private val ALLOWED_SCHEMES = setOf("https", "myapp") + + fun validate(uri: Uri): Boolean { + if (uri.scheme !in ALLOWED_SCHEMES) return false + if (uri.scheme == "https" && uri.host !in ALLOWED_HOSTS) return false + return true + } + + fun sanitizeArgument(value: String, maxLength: Int = 256): String { + return value.take(maxLength).replace(Regex("[^a-zA-Z0-9_\\-.]"), "") + } +} +``` + +Wire in `Activity`: +```kotlin +override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val deepLinkKey: NavKey = intent.data?.let { uri -> + if (!DeepLinkValidator.validate(uri)) return@let null + + val request = DeepLinkRequest(uri) + val match = deepLinkPatterns.firstNotNullOfOrNull { pattern -> + DeepLinkMatcher(request, pattern).match() + } + match?.let { + KeyDecoder(match.args).decodeSerializableValue(match.serializer) + } + } ?: HomeRoute + + // ... +} +``` + +Handle `onNewIntent` for `singleTask`: +```kotlin +override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + intent.data?.let { uri -> + if (DeepLinkValidator.validate(uri)) { + val key = parseDeepLink(uri) + // CORRECT: push key or reset stack - match onNewIntent for singleTask wiring. + } + } +} +``` + +Required: validate `scheme` and `host` against allowlists before parsing. + +Required: sanitize path segments and query values - attacker-controlled. + +Required: gate protected `NavKey` targets on auth state ([Conditional Navigation](#conditional-navigation)). + +Forbidden: load deep-link URLs in a `WebView` without an allowlist that matches the parser. + +Use HTTPS App Links for untrusted ingress. Forbidden: custom URI schemes as the only entry for auth, payments, or account recovery. + +Required: log deep-link attempts for anomaly detection ([crashlytics.md](references/crashlytics.md)). + +### Custom-Scheme Deep Linking + +Use HTTPS App Links for production ingress. Use a custom scheme (`myapp://`) only when: + +- The OAuth library or third-party SDK requires a non-HTTPS redirect URI. +- A vendor-internal IPC link must reach a sibling app on the same device. +- Internal QA shortcuts that never ship to production. + +Forbidden in app code for: payments, auth tokens, password reset, magic-link sign-in, anything that grants account access. Custom schemes are unverifiable - any other installed app can register the same scheme and silently steal the URL. + +Required: declare the custom scheme in a **separate** `` from the HTTPS App Links filter (see [AndroidManifest Setup](#androidmanifest-setup)). Mixing schemes inside one filter breaks `autoVerify` for the HTTPS hosts. + +```xml + + + + + + + + + + + + + + + + + + +``` + +Forbidden: `android:autoVerify="true"` on a custom-scheme filter - silently ignored. Verification is HTTPS-only. + +Required: route the custom scheme through the same `DeepLinkPattern` list and `DeepLinkValidator` allowlist as the HTTPS patterns. The validator's `ALLOWED_SCHEMES` set decides which schemes survive parsing. + +```kotlin +DeepLinkPattern( + serializer = UserProfile.serializer(), + pattern = "myapp://open/profile/{userId}".toUri() +), +``` + +Required when both HTTPS and a custom scheme reach the same `NavKey`: use HTTPS in every outbound link (email, SMS, push). Use the custom scheme only for intra-device callbacks where no HTTPS URL exists. + +Required for inbound custom-scheme links: validate the host as well as the scheme. `myapp://` with no `host` constraint matches `myapp://anything`, including paths an attacker can craft to confuse the parser. + +Custom-scheme `adb shell am start` probes: [testing.md → Testing Deep Links](/references/testing.md#testing-deep-links). + +### Testing Deep Links + +ADB, REST checks, instrumented `onNewIntent`, and host-state tables: [testing.md → Testing Deep Links](/references/testing.md#testing-deep-links). + +### Troubleshooting Deep Links + +Required: match a symptom row, run the linked ADB or REST check from [testing.md → Testing Deep Links](/references/testing.md#testing-deep-links), then edit manifest or server data. + +| Symptom | Likely cause | Fix | +|---------------------------------------------------------------|--------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------| +| Link opens browser instead of app | `assetlinks.json` unreachable, malformed, or fingerprint mismatch. | Hit the Digital Asset Links REST endpoint (see [App Links Verification](#app-links-verification)). Confirm uppercase SHA-256 and `application/json`. | +| Disambiguation dialog appears every time | User previously chose another handler, or hosts are only `DOMAIN_STATE_SELECTED`. | `pm set-app-links --package com.example.app 0 all` then `pm verify-app-links --re-verify com.example.app`. | +| Lowercase fingerprint in `assetlinks.json` | Generator produced lowercase, or hand-edited. | Convert to uppercase, colon-separated. Lowercase fails silently. | +| Debug APK ignores deep link | Debug fingerprint missing from `assetlinks.json`. | Add the debug-keystore SHA-256 alongside release/Play fingerprints. | +| Play-installed APK ignores deep link, side-loaded build works | Only the upload-key SHA-256 is published; runtime install carries the Play-managed signature. | Add the Play App Signing key SHA-256 from Play Console → Setup → App signing. | +| Verification works for one host, fails for others | One host in the same `autoVerify` filter has no `assetlinks.json`. Android 11 and lower fails the lot. | Publish the file on every host, or split unverifiable hosts into a separate filter without `autoVerify`. | +| Verification fails after server change | HTTP→HTTPS redirect, apex→www redirect, or `Content-Type: text/html`. | Serve the file directly with HTTP 200 and `application/json`. No redirects of any kind. | +| Apex domain works, `www` does not (or vice-versa) | Hosts treated as separate; only one has the file. | Publish the same JSON at both `https://example.com/.well-known/assetlinks.json` and `https://www.example.com/.well-known/assetlinks.json`. | +| `intent.data` is `null` after `onNewIntent` | `setIntent(intent)` not called. | See [onNewIntent for singleTask](#onnewintent-for-singletask) - the new intent must replace the cached one. | +| Activity restarts on every deep link | `launchMode` is `standard` or `singleTop`. | Set `android:launchMode="singleTask"` on the deep-link Activity. | +| Deep link drops the user on a screen with no Up target | No synthetic back stack on the new-task path. | Build one (see [Synthetic Back Stack](#synthetic-back-stack)) and use it when `Intent.FLAG_ACTIVITY_NEW_TASK` is present. | +| `pathPattern` matches unintended URLs | `.*` is greedy and matches `/anything`. | Anchor with explicit segments: `/orders/[^/]+/items/[^/]+`. | +| Custom-scheme link silently hijacked by another app | Custom schemes are unverifiable by design. | Move security-critical flows (auth, payments, magic links) to HTTPS App Links. See [Custom-Scheme Deep Linking](#custom-scheme-deep-linking). | +| Dynamic-rule update on Android 15+ not taking effect | Server cache; verifier has not re-fetched. | `pm verify-app-links --re-verify com.example.app`. See [Dynamic App Links](#dynamic-app-links-android-15-api-35). | +| `pm get-app-links` returns `none` on every host | Verifier has not run yet, or device offline. | Wait at least 20 seconds after install. Confirm network. Re-run `pm verify-app-links --re-verify`. | + +Forbidden: editing the manifest before reading `pm get-app-links` output. The status field tells you whether the failure is server-side (fingerprint, redirect) or client-side (filter, scheme, path). + +## Conditional Navigation + +Redirect users to a different flow based on app state (e.g., authentication, onboarding). The pattern uses a `requiresLogin` flag on navigation keys and a redirect mechanism. + +### Define Auth-Gated Keys + +```kotlin +@Serializable +sealed class AppNavKey(val requiresLogin: Boolean = false) : NavKey + +@Serializable +data object Home : AppNavKey() + +@Serializable +data object Profile : AppNavKey(requiresLogin = true) + +@Serializable +data class Login(val redirectToKey: AppNavKey? = null) : AppNavKey() +``` + +### Navigator with Auth Check + +```kotlin +class AppNavigator( + private val backStack: NavBackStack, + private val isLoggedIn: () -> Boolean, + private val onNavigateToRestrictedKey: (AppNavKey) -> Login +) { + fun navigate(route: AppNavKey) { + if (route.requiresLogin && !isLoggedIn()) { + backStack.add(onNavigateToRestrictedKey(route)) + } else { + backStack.add(route) + } + } + + fun goBack() { + backStack.removeLastOrNull() + } +} +``` + +### Wire Up in Composable + +```kotlin +@Composable +fun ConditionalNavExample() { + val backStack = rememberNavBackStack(Home) + var isLoggedIn by rememberSaveable { mutableStateOf(false) } + + val navigator = remember { + AppNavigator( + backStack = backStack, + isLoggedIn = { isLoggedIn }, + onNavigateToRestrictedKey = { redirectToKey -> Login(redirectToKey) } + ) + } + + NavDisplay( + backStack = backStack, + onBack = { navigator.goBack() }, + entryProvider = entryProvider { + entry { + HomeScreen( + isLoggedIn = isLoggedIn, + onProfileClick = dropUnlessResumed { navigator.navigate(Profile) }, + onLoginClick = dropUnlessResumed { navigator.navigate(Login()) } + ) + } + entry { + ProfileScreen( + onLogout = dropUnlessResumed { + isLoggedIn = false + navigator.navigate(Home) + } + ) + } + entry { key -> + LoginScreen( + onLoginSuccess = dropUnlessResumed { + isLoggedIn = true + key.redirectToKey?.let { target -> + backStack.remove(key) + navigator.navigate(target) + } + } + ) + } + } + ) +} +``` + +**How it works:** +- Navigating to `Profile` while logged out redirects to `Login(redirectToKey = Profile)` +- After successful login, the `Login` entry is removed from the back stack and the user is sent to the original target +- `dropUnlessResumed` prevents navigation during transitions (e.g., double-clicks) +- Use `rememberSaveable` for `isLoggedIn` so auth state survives configuration changes; in production, back this with a ViewModel or repository + +## Returning Results + +Pass data back from one screen to another. Navigation 3 offers two patterns: event-based (one-shot delivery) and callback-based (via Navigator interface). + +### Callback-Based Results + +Define the result callback on the Navigator interface and let the app module own the hoisted state. + +**1. Feature module defines the callback:** +```kotlin +// feature/picker/navigation/ColorPickerNavigator.kt +interface ColorPickerNavigator { + fun navigateBackWithColor(color: String) + fun navigateBack() +} +``` + +**2. App module implements it by modifying the caller's state:** +```kotlin +// app/navigation/AppNavigation.kt +@Composable +fun AppNavigation() { + val backStack = rememberNavBackStack(HomeRoute) + var selectedColor by rememberSaveable { mutableStateOf(null) } + + val colorPickerNavigator = remember { + object : ColorPickerNavigator { + override fun navigateBackWithColor(color: String) { + selectedColor = color + backStack.removeLastOrNull() + } + override fun navigateBack() { + backStack.removeLastOrNull() + } + } + } + + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryProvider = entryProvider { + entry { + HomeScreen( + selectedColor = selectedColor, + onPickColor = dropUnlessResumed { + backStack.add(ColorPickerRoute) + } + ) + } + entry { + ColorPickerScreen(navigator = colorPickerNavigator) + } + } + ) +} +``` + +### Event-Based Results + +For decoupled result delivery without direct state hoisting, use a result map keyed by the caller's content key: + +```kotlin +@Composable +fun EventResultExample() { + val backStack = rememberNavBackStack(ScreenA) + val resultMap = remember { mutableMapOf() } + + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryProvider = entryProvider { + entry { + val result = resultMap.remove(ScreenA) as? String + + LaunchedEffect(result) { + result?.let { name -> + // Handle the returned result + } + } + + ScreenAContent( + lastResult = result, + onRequestName = dropUnlessResumed { + backStack.add(ScreenB) + } + ) + } + entry { + ScreenBContent( + onReturnName = dropUnlessResumed { name -> + resultMap[ScreenA] = name + backStack.removeLastOrNull() + } + ) + } + } + ) +} +``` + +### State-Based Results (CompositionLocal) + +Use when several screens must observe the same result (global "selected filter", multi-step wizard value). Expose the result as **state via a `CompositionLocal`** scoped to the `NavDisplay`. Receivers read the value; producers write it before popping. + +```kotlin +class FilterResultHolder { + var value by mutableStateOf(null) + private set + + fun set(result: FilterResult) { value = result } + fun consume(): FilterResult? = value.also { value = null } +} + +val LocalFilterResult = compositionLocalOf { + error("FilterResultHolder not provided") +} + +@Composable +fun AppNavigation() { + val backStack = rememberNavBackStack(HomeRoute) + val filterResult = remember { FilterResultHolder() } + + CompositionLocalProvider(LocalFilterResult provides filterResult) { + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryProvider = entryProvider { + entry { + val applied = LocalFilterResult.current.value + HomeScreen( + appliedFilter = applied, + onOpenFilters = dropUnlessResumed { backStack.add(FiltersRoute) } + ) + } + entry { + FiltersScreen( + onApply = dropUnlessResumed { result -> + LocalFilterResult.current.set(result) + backStack.removeLastOrNull() + } + ) + } + } + ) + } +} +``` + +**Required:** +- Scope result holders to the `backStack` (`remember` inside `AppNavigation`) so they survive stack mutations and dispose with `NavDisplay`. +- Receivers **read** `LocalFilterResult.current.value` like any other state — skip `LaunchedEffect` bridges. +- One-shot results expose `consume()` that clears after read; sticky results expose `value` directly. +- One `CompositionLocal` holder per result type — no generic cross-feature result bus. + +### Choosing a pattern + +| Pattern | Use when | Avoid when | +|--------------------------------|-----------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------| +| Callback-based | Default. Result is type-safe and the caller already exposes hoisted state. | Caller cannot hold the receiving state (cross-feature). | +| Event-based | Receiver is decoupled from the Navigator and you only need a one-shot delivery. | You need Compose-observable updates or shared state. | +| State-based (CompositionLocal) | Several screens read the same result, or the receiver wants idiomatic Compose state instead of callbacks. | A single caller/receiver pair (use callback-based) or cross-process delivery is needed. | + +Default to callback-based; it stays type-safe and matches the `Navigator` interface pattern used everywhere else. Reach for state-based only when multiple consumers are involved. + +## ViewModel Scoping + +By default, ViewModels are scoped to the Activity. Navigation 3 provides `NavEntryDecorator` to scope ViewModels to individual back stack entries - the ViewModel is created when the entry is added and cleared when it is popped. + +### NavEntryDecorators + +Add decorators to `NavDisplay` via the `entryDecorators` parameter: + +```kotlin +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay + +NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator() + ), + entryProvider = entryProvider { + // ViewModels created inside entries are now scoped to that entry + } +) +``` + +**Built-in decorators:** +- `rememberSaveableStateHolderNavEntryDecorator()` - saves/restores UI state (included by default) +- `rememberViewModelStoreNavEntryDecorator()` - provides a `ViewModelStoreOwner` per entry, so `viewModel()` and `hiltViewModel()` are scoped to the entry's lifetime on the back stack + +**Dependency:** `androidx.lifecycle:lifecycle-viewmodel-navigation3` (already in `assets/libs.versions.toml.template`) + +### Scoping to a non-screen composable + +Use [`rememberViewModelStoreOwner()`](https://developer.android.com/reference/kotlin/androidx/lifecycle/viewmodel/compose/package-summary#rememberViewModelStoreOwner\(\)) only for genuinely complex, single-instance, non-screen composables (media-player widget, multi-step wizard, in-page editor). The default remains screen-level - see [`hiltViewModel()` Scope Mistakes](#hiltviewmodel-scope-mistakes). + +Required: + +- The composable encapsulates non-trivial state that does not belong on the parent screen's `UiState`. +- Single-instance at the call site. Forbidden inside `LazyColumn` items, `ProductCard`, or any list/grid cell. +- Hoist state to the parent screen first; reach for a scoped ViewModel only after that fails. + +```kotlin +@Composable +fun MediaPlayerWidget(uri: Uri) { + val owner = rememberViewModelStoreOwner() + CompositionLocalProvider(LocalViewModelStoreOwner provides owner) { + val viewModel: MediaPlayerViewModel = hiltViewModel() + // ViewModel is cleared when this composable leaves the composition + MediaPlayer(state = viewModel.uiState, onAction = viewModel::onAction) + } +} +``` + +### Passing NavKey Arguments to Hilt ViewModels + +Navigation 3 uses assisted injection to pass `NavKey` arguments directly to ViewModels: + +**1. Define the ViewModel with assisted `NavKey`:** +```kotlin +// feature/products/presentation/ProductDetailViewModel.kt +@HiltViewModel(assistedFactory = ProductDetailViewModel.Factory::class) +class ProductDetailViewModel @AssistedInject constructor( + @Assisted private val productKey: ProductsDestination.ProductDetail, + private val getProductUseCase: GetProductUseCase +) : ViewModel() { + + val uiState: StateFlow = getProductUseCase(productKey.productId) + .map { ProductDetailUiState(product = it) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ProductDetailUiState()) + + @AssistedFactory + interface Factory { + fun create(productKey: ProductsDestination.ProductDetail): ProductDetailViewModel + } +} +``` + +**2. Use in the entry with `hiltViewModel`:** +```kotlin +entry { key -> + val viewModel = hiltViewModel { factory -> + factory.create(key) + } + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + ProductDetailScreen(state = uiState) +} +``` + +This approach is type-safe, avoids `SavedStateHandle` string-key lookups, and works with Hilt's dependency graph. + +### Shared ViewModel Between Screens + +Share a ViewModel between a parent and child entry using a custom `NavEntryDecorator`: + +**1. Create the shared decorator:** +```kotlin +// app/navigation/SharedViewModelStoreNavEntryDecorator.kt +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavEntryDecorator + +class SharedViewModelStoreNavEntryDecorator : NavEntryDecorator { + + @Composable + override fun DecorateEntry(entry: NavEntry<*>) { + val parentKey = entry.metadata[PARENT_KEY] as? Any + val currentOwner = LocalViewModelStoreOwner.current + + if (parentKey != null && currentOwner != null) { + // Child entry uses parent's ViewModelStoreOwner + entry.Content() + } else { + entry.Content() + } + } + + override fun onPop(contentKey: Any) { } + + companion object { + private const val PARENT_KEY = "SharedViewModelStore-Parent" + + fun parent(parentContentKey: Any) = mapOf(PARENT_KEY to parentContentKey) + } +} + +@Composable +fun rememberSharedViewModelStoreNavEntryDecorator(): SharedViewModelStoreNavEntryDecorator { + return remember { SharedViewModelStoreNavEntryDecorator() } +} +``` + +**2. Use in NavDisplay:** +```kotlin +NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberSharedViewModelStoreNavEntryDecorator(), + ), + entryProvider = entryProvider { + entry( + clazzContentKey = { key -> key.toContentKey() }, + ) { + val viewModel = viewModel() + ParentContent(count = viewModel.count, onIncrement = { viewModel.count++ }) + } + entry( + metadata = SharedViewModelStoreNavEntryDecorator.parent( + ParentScreen.toContentKey() + ), + ) { + val parentViewModel = viewModel() + ChildContent(parentCount = parentViewModel.count) + } + } +) + +fun NavKey.toContentKey() = this.toString() +``` + +The child entry's `viewModel()` call resolves to the same instance as the parent's, because both share the same `ViewModelStoreOwner`. + +## Navigation Anti-Patterns + +### `hiltViewModel()` Scope Mistakes + +```kotlin +// Bad: hiltViewModel() inside a nested composable (wrong scope) +@Composable +fun ProductCard() { + // ViewModelStore follows the NavEntry — every ProductCard shares one ViewModel. + // Multiple ProductCards will share the exact same ViewModel instance. + val viewModel: ProductViewModel = hiltViewModel() +} + +// Good: Pass state and callbacks down from the route/screen level +@Composable +fun ProductCard(product: Product, onClick: () -> Unit) { + // Pure UI component +} +``` + +Escape hatch for genuinely complex, single-instance, non-screen composables: [Scoping to a non-screen composable](#scoping-to-a-non-screen-composable). Never apply inside list cells. + +### ViewModel Navigation + +```kotlin +// Bad: Passing Navigator to ViewModel (breaks unidirectional data flow and testability) +class AuthViewModel(private val navigator: AuthNavigator) : ViewModel() { + fun login() { + // ... + navigator.navigateToMainApp() // ViewModel shouldn't drive navigation directly + } +} + +// Good: Emit a one-shot event, let the Route composable handle navigation +class AuthViewModel : ViewModel() { + private val _events = Channel() + val events = _events.receiveAsFlow() + + fun login() { + // ... + _events.trySend(AuthEvent.LoginSuccess) + } +} +``` + +### Passing Complex Objects in NavKeys + +```kotlin +// Bad: Passing large or complex objects in navigation routes +@Serializable +data class ProductDetail( + val product: Product // Product can exceed SavedStateHandle limits or hold non-Parcelable fields +) : ProductsDestination + +// Good: Pass only IDs, fetch data in the destination +@Serializable +data class ProductDetail( + val productId: String // Small, easily serializable ID +) : ProductsDestination +``` diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-notifications.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-notifications.md new file mode 100644 index 000000000..f89215c8e --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-notifications.md @@ -0,0 +1,1239 @@ +# Android Notifications + +Notification patterns aligned with Material Design 3: channel management, actions, and foreground services. + +All Kotlin code must align with `references/kotlin-patterns.md`. Permission handling lives in `references/android-permissions.md`. WorkManager-backed background sync lives in `references/android-data-sync.md`. + +## Table of Contents + +- [Notification Channels (API 26+)](#notification-channels-api-26) +- [Basic Notifications](#basic-notifications) +- [Notification Styles](#notification-styles) +- [Action Buttons](#action-buttons) +- [Progress Notifications](#progress-notifications) +- [Progress-Centric Notifications (API 36+)](#progress-centric-notifications-api-36) +- [Foreground Service Notifications](#foreground-service-notifications) +- [Media, PiP, Sharing, and Background Work](#media-pip-sharing-and-background-work) +- [Navigation State (Navigation3)](#navigation-state-navigation3) +- [Notification Manager Interface](#notification-manager-interface) +- [Architecture Integration](#architecture-integration) +- [Testing](#testing) +- [Notification routing](#notification-routing) + +## Notification Channels (API 26+) + +Notification channels are **required** for API 26+ but are no-ops on API 24-25. + +### Channel Creation + +Create channels once at app startup: + +```kotlin +// core/notifications/NotificationChannels.kt +package com.example.core.notifications + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationManagerCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NotificationChannels @Inject constructor( + @ApplicationContext private val context: Context +) { + companion object { + const val CHANNEL_GENERAL = "general" + const val CHANNEL_DOWNLOADS = "downloads" + const val CHANNEL_MESSAGES = "messages" + const val CHANNEL_FOREGROUND_SERVICE = "foreground_service" + } + + fun createAllChannels() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channels = listOf( + NotificationChannel( + CHANNEL_GENERAL, + "General Notifications", + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = "General app notifications" + }, + + NotificationChannel( + CHANNEL_DOWNLOADS, + "Downloads", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Download progress notifications" + setShowBadge(false) + }, + + NotificationChannel( + CHANNEL_MESSAGES, + "Messages", + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "New message notifications" + enableVibration(true) + enableLights(true) + }, + + NotificationChannel( + CHANNEL_FOREGROUND_SERVICE, + "Background Tasks", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Ongoing background operations" + setShowBadge(false) + } + ) + + val notificationManager = NotificationManagerCompat.from(context) + channels.forEach { channel -> + notificationManager.createNotificationChannel(channel) + } + } + } +} +``` + +### Initialize in Application + +```kotlin +// app/MyApplication.kt +@HiltAndroidApp +class MyApplication : Application() { + @Inject lateinit var notificationChannels: NotificationChannels + + override fun onCreate() { + super.onCreate() + notificationChannels.createAllChannels() + } +} +``` + +## Basic Notifications + +### Simple Notification + +```kotlin +import android.Manifest +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.example.core.notifications.NotificationChannels +import javax.inject.Inject + +class NotificationHelper @Inject constructor( + private val context: Context +) { + fun showSimpleNotification( + title: String, + message: String, + notificationId: Int = System.currentTimeMillis().toInt() + ) { + // Check permission for API 33+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val permissionGranted = context.checkSelfPermission( + Manifest.permission.POST_NOTIFICATIONS + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + + if (!permissionGranted) return + } + + val notification = NotificationCompat.Builder(context, NotificationChannels.CHANNEL_GENERAL) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(title) + .setContentText(message) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setAutoCancel(true) + .build() + + NotificationManagerCompat.from(context).notify(notificationId, notification) + } +} +``` + +### Notification with Tap Action + +Use `PendingIntent` to open a specific screen: + +```kotlin +fun showNotificationWithAction( + title: String, + message: String, + targetRoute: String, + notificationId: Int = System.currentTimeMillis().toInt() +) { + // Check permission for API 33+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val permissionGranted = context.checkSelfPermission( + Manifest.permission.POST_NOTIFICATIONS + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + + if (!permissionGranted) return + } + + // Deep link to specific screen + val intent = Intent( + Intent.ACTION_VIEW, + "app://example.com/$targetRoute".toUri(), + context, + MainActivity::class.java + ).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + + val pendingIntent = PendingIntent.getActivity( + context, + notificationId, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(context, NotificationChannels.CHANNEL_GENERAL) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(title) + .setContentText(message) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .build() + + NotificationManagerCompat.from(context).notify(notificationId, notification) +} +``` + +## Notification Styles + +### BigTextStyle + +For long text content: + +```kotlin +fun showBigTextNotification( + title: String, + shortText: String, + longText: String, + notificationId: Int = System.currentTimeMillis().toInt() +) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val permissionGranted = context.checkSelfPermission( + Manifest.permission.POST_NOTIFICATIONS + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + + if (!permissionGranted) return + } + + val notification = NotificationCompat.Builder(context, NotificationChannels.CHANNEL_GENERAL) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(title) + .setContentText(shortText) // Shown when collapsed + .setStyle( + NotificationCompat.BigTextStyle() + .bigText(longText) // Shown when expanded + .setBigContentTitle(title) + ) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setAutoCancel(true) + .build() + + NotificationManagerCompat.from(context).notify(notificationId, notification) +} +``` + +### BigPictureStyle + +For image notifications: + +```kotlin +fun showBigPictureNotification( + title: String, + text: String, + bitmap: Bitmap, + notificationId: Int = System.currentTimeMillis().toInt() +) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val permissionGranted = context.checkSelfPermission( + Manifest.permission.POST_NOTIFICATIONS + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + + if (!permissionGranted) return + } + + val notification = NotificationCompat.Builder(context, NotificationChannels.CHANNEL_GENERAL) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(title) + .setContentText(text) + .setLargeIcon(bitmap) // Shown when collapsed + .setStyle( + NotificationCompat.BigPictureStyle() + .bigPicture(bitmap) // Shown when expanded + .setBigContentTitle(title) + .setSummaryText(text) + ) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setAutoCancel(true) + .build() + + NotificationManagerCompat.from(context).notify(notificationId, notification) +} +``` + +### InboxStyle + +For multiple lines of information: + +```kotlin +fun showInboxStyleNotification( + title: String, + lines: List, + summaryText: String? = null, + notificationId: Int = System.currentTimeMillis().toInt() +) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val permissionGranted = context.checkSelfPermission( + Manifest.permission.POST_NOTIFICATIONS + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + + if (!permissionGranted) return + } + + val inboxStyle = NotificationCompat.InboxStyle() + lines.forEach { line -> + inboxStyle.addLine(line) + } + + summaryText?.let { inboxStyle.setSummaryText(it) } + + val notification = NotificationCompat.Builder(context, NotificationChannels.CHANNEL_MESSAGES) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(title) + .setContentText("${lines.size} new messages") + .setStyle(inboxStyle) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .build() + + NotificationManagerCompat.from(context).notify(notificationId, notification) +} +``` + +### MessagingStyle + +For conversations (API 24+): + +```kotlin +fun showMessagingStyleNotification( + conversationTitle: String, + messages: List, + currentUser: Person, + notificationId: Int = System.currentTimeMillis().toInt() +) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val permissionGranted = context.checkSelfPermission( + Manifest.permission.POST_NOTIFICATIONS + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + + if (!permissionGranted) return + } + + val messagingStyle = NotificationCompat.MessagingStyle(currentUser) + .setConversationTitle(conversationTitle) + + messages.forEach { message -> + messagingStyle.addMessage( + NotificationCompat.MessagingStyle.Message( + message.text, + message.timestamp, + message.sender + ) + ) + } + + val notification = NotificationCompat.Builder(context, NotificationChannels.CHANNEL_MESSAGES) + .setSmallIcon(R.drawable.ic_notification) + .setStyle(messagingStyle) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .build() + + NotificationManagerCompat.from(context).notify(notificationId, notification) +} + +data class Message( + val text: String, + val timestamp: Long, + val sender: Person +) +``` + +## Action Buttons + +### Basic Actions + +```kotlin +fun showNotificationWithActions( + title: String, + message: String, + notificationId: Int = System.currentTimeMillis().toInt() +) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val permissionGranted = context.checkSelfPermission( + Manifest.permission.POST_NOTIFICATIONS + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + + if (!permissionGranted) return + } + + // Create PendingIntents for actions + val acceptIntent = createBroadcastIntent(ACTION_ACCEPT, notificationId) + val declineIntent = createBroadcastIntent(ACTION_DECLINE, notificationId) + + val notification = NotificationCompat.Builder(context, NotificationChannels.CHANNEL_GENERAL) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(title) + .setContentText(message) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .addAction( + R.drawable.ic_check, + "Accept", + acceptIntent + ) + .addAction( + R.drawable.ic_close, + "Decline", + declineIntent + ) + .setAutoCancel(true) + .build() + + NotificationManagerCompat.from(context).notify(notificationId, notification) +} + +private fun createBroadcastIntent(action: String, notificationId: Int): PendingIntent { + val intent = Intent(context, NotificationActionReceiver::class.java).apply { + this.action = action + putExtra(EXTRA_NOTIFICATION_ID, notificationId) + } + + return PendingIntent.getBroadcast( + context, + notificationId, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) +} + +companion object { + const val ACTION_ACCEPT = "com.example.ACTION_ACCEPT" + const val ACTION_DECLINE = "com.example.ACTION_DECLINE" + const val EXTRA_NOTIFICATION_ID = "notification_id" +} +``` + +### Notification Action Receiver + +Handle notification actions in a BroadcastReceiver: + +```kotlin +// core/notifications/NotificationActionReceiver.kt +@AndroidEntryPoint +class NotificationActionReceiver : BroadcastReceiver() { + @Inject lateinit var notificationRepository: NotificationRepository + + override fun onReceive(context: Context, intent: Intent) { + val notificationId = intent.getIntExtra( + NotificationHelper.EXTRA_NOTIFICATION_ID, + -1 + ) + + when (intent.action) { + NotificationHelper.ACTION_ACCEPT -> { + // Handle accept action + notificationRepository.handleAccept(notificationId) + dismissNotification(context, notificationId) + } + NotificationHelper.ACTION_DECLINE -> { + // Handle decline action + notificationRepository.handleDecline(notificationId) + dismissNotification(context, notificationId) + } + } + } + + private fun dismissNotification(context: Context, notificationId: Int) { + NotificationManagerCompat.from(context).cancel(notificationId) + } +} +``` + +Register in AndroidManifest.xml: + +```xml + + + + + + +``` + +## Progress Notifications + +### Determinate Progress + +```kotlin +fun showProgressNotification( + title: String, + progress: Int, + maxProgress: Int = 100, + notificationId: Int +) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val permissionGranted = context.checkSelfPermission( + Manifest.permission.POST_NOTIFICATIONS + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + + if (!permissionGranted) return + } + + val notification = NotificationCompat.Builder(context, NotificationChannels.CHANNEL_DOWNLOADS) + .setSmallIcon(R.drawable.ic_download) + .setContentTitle(title) + .setContentText("Downloading...") + .setProgress(maxProgress, progress, false) + .setOngoing(true) // Prevent dismissal while in progress + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + + NotificationManagerCompat.from(context).notify(notificationId, notification) +} + +fun showDownloadCompleteNotification( + title: String, + notificationId: Int +) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val permissionGranted = context.checkSelfPermission( + Manifest.permission.POST_NOTIFICATIONS + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + + if (!permissionGranted) return + } + + val notification = NotificationCompat.Builder(context, NotificationChannels.CHANNEL_DOWNLOADS) + .setSmallIcon(R.drawable.ic_check) + .setContentTitle(title) + .setContentText("Download complete") + .setProgress(0, 0, false) // Remove progress bar + .setOngoing(false) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + + NotificationManagerCompat.from(context).notify(notificationId, notification) +} +``` + +### Indeterminate Progress + +```kotlin +fun showIndeterminateProgressNotification( + title: String, + message: String, + notificationId: Int +) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val permissionGranted = context.checkSelfPermission( + Manifest.permission.POST_NOTIFICATIONS + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + + if (!permissionGranted) return + } + + val notification = NotificationCompat.Builder(context, NotificationChannels.CHANNEL_DOWNLOADS) + .setSmallIcon(R.drawable.ic_sync) + .setContentTitle(title) + .setContentText(message) + .setProgress(0, 0, true) // Indeterminate + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + + NotificationManagerCompat.from(context).notify(notificationId, notification) +} +``` + +## Progress-Centric Notifications (API 36+) + +Android 16 introduces `Notification.ProgressStyle`, a rich notification style for tracking user-initiated journeys from start to end. Use this for rideshare, delivery, navigation, and any multi-step process. + +### ProgressStyle Notification + +```kotlin +fun showProgressStyleNotification( + title: String, + currentSegmentText: String, + notificationId: Int +) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.BAKLAVA) { + showProgressNotification(title, 50, notificationId = notificationId) + return + } + + val progressStyle = Notification.ProgressStyle().apply { + addSegment(Notification.ProgressStyle.Segment(500).apply { + color = android.graphics.Color.GREEN + }) + addSegment(Notification.ProgressStyle.Segment(1000)) + addPoint(Notification.ProgressStyle.Point(750).apply { + color = android.graphics.Color.RED + }) + progress = 250 + progressTrackerIcon = Icon.createWithResource(context, R.drawable.ic_car) + } + + val notification = Notification.Builder(context, NotificationChannels.CHANNEL_GENERAL) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(title) + .setContentText(currentSegmentText) + .setStyle(progressStyle) + .setOngoing(true) + .build() + + NotificationManagerCompat.from(context).notify(notificationId, notification) +} +``` + +**Key concepts:** +- **Segments**: Divide the journey into phases with optional colors +- **Points**: Mark milestones along the journey (e.g., pickup, dropoff) +- **Progress**: Current position along the total journey +- **Tracker icon**: Visual indicator of the current position + +**When to use ProgressStyle vs standard progress:** +- Use `ProgressStyle` for multi-step user journeys (rideshare, delivery, navigation) +- Use standard `setProgress()` for simple determinate/indeterminate tasks (downloads, uploads) +- `ProgressStyle` is only available on API 36+; provide a fallback for older APIs + +## Foreground Service Notifications + +Foreground services **require** a notification on all API levels. + +### Foreground Service Setup + +```kotlin +// core/sync/SyncWorker.kt +@HiltWorker +class SyncWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted params: WorkerParameters, + private val syncRepository: SyncRepository +) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + setForeground(createForegroundInfo()) + + try { + syncRepository.sync() + Result.success() + } catch (e: Exception) { + Result.retry() + } + } + + private fun createForegroundInfo(): ForegroundInfo { + val notification = NotificationCompat.Builder( + applicationContext, + NotificationChannels.CHANNEL_FOREGROUND_SERVICE + ) + .setSmallIcon(R.drawable.ic_sync) + .setContentTitle("Syncing data") + .setContentText("Synchronizing your data in the background...") + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } else { + ForegroundInfo(NOTIFICATION_ID, notification) + } + } + + companion object { + private const val NOTIFICATION_ID = 1001 + } +} +``` + +### Foreground Service in Android Service + +For long-running operations: + +```kotlin +// core/sync/SyncForegroundService.kt +@AndroidEntryPoint +class SyncForegroundService : Service() { + @Inject lateinit var syncRepository: SyncRepository + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val notification = createNotification() + + // Start foreground BEFORE doing work + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } else { + startForeground(NOTIFICATION_ID, notification) + } + + scope.launch { + try { + syncRepository.sync() + } finally { + stopSelf() + } + } + + return START_NOT_STICKY + } + + override fun onDestroy() { + super.onDestroy() + scope.cancel() + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun createNotification(): Notification { + return NotificationCompat.Builder( + this, + NotificationChannels.CHANNEL_FOREGROUND_SERVICE + ) + .setSmallIcon(R.drawable.ic_sync) + .setContentTitle("Syncing data") + .setContentText("Synchronizing your data...") + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + } + + companion object { + private const val NOTIFICATION_ID = 1001 + } +} +``` + +Declare in AndroidManifest.xml: + +```xml + + + + + + + +``` + +## Media, PiP, Sharing, and Background Work + +### Audio focus + +If your app plays audio, request audio focus with `AudioManager` / `AudioFocusRequest`. React when other apps take focus: + +| Change | Typical app action | +|---------------------|------------------------------------------------------| +| Permanent loss | Stop playback and release resources | +| Transient loss | Pause until focus returns | +| Transient, can duck | Lower volume (duck) instead of full pause | +| Focus gained | Resume or restore volume if the user had not stopped | + +Do not start playback without focus. For long-running playback in the background, use a **foreground service** with a **MediaStyle** notification and a **`MediaSession`** so lock screen and Bluetooth controls stay in sync. Exact service types and permissions depend on API level and use case; follow [playback](https://developer.android.com/media/legacy/audio/mediaplayer) and [foreground service](https://developer.android.com/develop/background-work/services/foreground-services) documentation. + +### Picture-in-picture (video) + +For video activities, support **PiP** when users leave during playback: declare `android:supportsPictureInPicture="true"` on the activity, call `enterPictureInPictureMode()` when appropriate, and keep aspect ratio within supported bounds. Pair with ongoing media notifications when playback continues in the background. + +### System sharesheet + +Use the system **chooser** for sharing content instead of custom share UIs for the same job: + +```kotlin +val send = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, shareText) +} +context.startActivity(Intent.createChooser(send, null)) +``` + +### Background work vs long-running services + +- **Deferrable work** (sync, uploads, cleanup): **WorkManager** (see `references/android-data-sync.md`). +- **User-visible ongoing work**: foreground service with notification. +- **Push-triggered updates**: **FCM** or high-priority pushes where appropriate, not a permanent background socket unless the product truly requires it. + +Avoid holding wake locks or silent background services for tasks WorkManager can schedule. + +## Navigation State (Navigation3) + +Notification actions and deep links often need to align with **where** the user lands in the app. + +- Use **Navigation3** `rememberNavBackStack` / `NavDisplay` patterns from `references/android-navigation.md` so the **back stack** matches user expectations when opening a screen from a notification. +- Persist **process death** state with **SavedStateHandle** in ViewModels and `references/compose-patterns.md` (not a separate "NavController" graph for Navigation3-only apps). + +Treat notification taps like cold entry: resolve the target destination, then push or replace stack entries so **Back** returns to a sensible place. + +## Notification Manager Interface + +Wrap notification dispatch behind an interface in `core/notifications`. Inject the interface, never `NotificationManagerCompat`, into ViewModels and use cases. This keeps the dispatcher swappable for fakes in unit tests. + +```kotlin +// core/notifications/NotificationManager.kt +package com.example.core.notifications + +interface NotificationManager { + fun showNotification( + title: String, + message: String, + notificationId: Int = generateId() + ) + + fun showNotificationWithAction( + title: String, + message: String, + targetRoute: String, + notificationId: Int = generateId() + ) + + fun showProgressNotification( + title: String, + progress: Int, + maxProgress: Int = 100, + notificationId: Int + ) + + fun dismissNotification(notificationId: Int) + + fun dismissAllNotifications() + + companion object { + fun generateId(): Int = System.currentTimeMillis().toInt() + } +} +``` + +### Implementation + +```kotlin +// core/notifications/AndroidNotificationManager.kt +@Singleton +class AndroidNotificationManager @Inject constructor( + @ApplicationContext private val context: Context +) : NotificationManager { + + private val notificationManagerCompat = NotificationManagerCompat.from(context) + + override fun showNotification( + title: String, + message: String, + notificationId: Int + ) { + if (!hasNotificationPermission()) return + + val notification = NotificationCompat.Builder( + context, + NotificationChannels.CHANNEL_GENERAL + ) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(title) + .setContentText(message) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setAutoCancel(true) + .build() + + notificationManagerCompat.notify(notificationId, notification) + } + + override fun showNotificationWithAction( + title: String, + message: String, + targetRoute: String, + notificationId: Int + ) { + if (!hasNotificationPermission()) return + + val intent = Intent( + Intent.ACTION_VIEW, + "app://example.com/$targetRoute".toUri(), + context, + MainActivity::class.java + ).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + + val pendingIntent = PendingIntent.getActivity( + context, + notificationId, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder( + context, + NotificationChannels.CHANNEL_GENERAL + ) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(title) + .setContentText(message) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .build() + + notificationManagerCompat.notify(notificationId, notification) + } + + override fun showProgressNotification( + title: String, + progress: Int, + maxProgress: Int, + notificationId: Int + ) { + if (!hasNotificationPermission()) return + + val notification = NotificationCompat.Builder( + context, + NotificationChannels.CHANNEL_DOWNLOADS + ) + .setSmallIcon(R.drawable.ic_download) + .setContentTitle(title) + .setContentText("Downloading...") + .setProgress(maxProgress, progress, false) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + + notificationManagerCompat.notify(notificationId, notification) + } + + override fun dismissNotification(notificationId: Int) { + notificationManagerCompat.cancel(notificationId) + } + + override fun dismissAllNotifications() { + notificationManagerCompat.cancelAll() + } + + private fun hasNotificationPermission(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == + android.content.pm.PackageManager.PERMISSION_GRANTED + } else { + true // No permission required on API < 33 + } + } +} +``` + +### Hilt Module + +```kotlin +// core/di/NotificationModule.kt +@Module +@InstallIn(SingletonComponent::class) +abstract class NotificationModule { + @Binds + abstract fun bindNotificationManager( + impl: AndroidNotificationManager + ): NotificationManager +} +``` + +## Architecture Integration + +### Repository Layer + +```kotlin +// feature/messages/data/MessageRepository.kt +interface MessageRepository { + suspend fun sendMessage(text: String): Result + fun observeNewMessages(): Flow +} + +class MessageRepositoryImpl @Inject constructor( + private val messageApi: MessageApi, + private val notificationManager: NotificationManager +) : MessageRepository { + + override suspend fun sendMessage(text: String): Result = runCatching { + messageApi.sendMessage(text) + } + + override fun observeNewMessages(): Flow = flow { + // Observe new messages from remote source + messageApi.observeMessages().collect { message -> + // Show notification for new messages + notificationManager.showNotificationWithAction( + title = message.senderName, + message = message.text, + targetRoute = "messages/${message.id}" + ) + emit(message) + } + } +} +``` + +### ViewModel Layer + +```kotlin +// feature/sync/presentation/SyncViewModel.kt +@HiltViewModel +class SyncViewModel @Inject constructor( + private val syncRepository: SyncRepository, + private val notificationManager: NotificationManager +) : ViewModel() { + + private val notificationId = NotificationManager.generateId() + + fun startSync() { + viewModelScope.launch { + syncRepository.sync() + .onStart { + notificationManager.showProgressNotification( + title = "Syncing", + progress = 0, + notificationId = notificationId + ) + } + .collect { progress -> + notificationManager.showProgressNotification( + title = "Syncing", + progress = progress, + notificationId = notificationId + ) + } + } + } +} +``` + +## Testing + +### Fake NotificationManager + +```kotlin +// core/notifications/testing/FakeNotificationManager.kt +class FakeNotificationManager : NotificationManager { + private val _notifications = mutableListOf() + val notifications: List = _notifications + + override fun showNotification( + title: String, + message: String, + notificationId: Int + ) { + _notifications.add( + NotificationData( + id = notificationId, + title = title, + message = message + ) + ) + } + + override fun showNotificationWithAction( + title: String, + message: String, + targetRoute: String, + notificationId: Int + ) { + _notifications.add( + NotificationData( + id = notificationId, + title = title, + message = message, + targetRoute = targetRoute + ) + ) + } + + override fun showProgressNotification( + title: String, + progress: Int, + maxProgress: Int, + notificationId: Int + ) { + val existing = _notifications.find { it.id == notificationId } + if (existing != null) { + _notifications.remove(existing) + } + + _notifications.add( + NotificationData( + id = notificationId, + title = title, + progress = progress, + maxProgress = maxProgress + ) + ) + } + + override fun dismissNotification(notificationId: Int) { + _notifications.removeAll { it.id == notificationId } + } + + override fun dismissAllNotifications() { + _notifications.clear() + } + + fun assertNotificationShown(title: String) { + assert(_notifications.any { it.title == title }) + } + + fun assertNotificationCount(expected: Int) { + assert(_notifications.size == expected) + } +} + +data class NotificationData( + val id: Int, + val title: String, + val message: String? = null, + val targetRoute: String? = null, + val progress: Int? = null, + val maxProgress: Int? = null +) +``` + +### Testing Repository + +```kotlin +// feature/messages/data/MessageRepositoryTest.kt +@Test +fun `observeNewMessages shows notification for each message`() = runTest { + val fakeNotificationManager = FakeNotificationManager() + val repository = MessageRepositoryImpl( + messageApi = fakeMessageApi, + notificationManager = fakeNotificationManager + ) + + repository.observeNewMessages().take(2).collect() + + fakeNotificationManager.assertNotificationCount(2) + fakeNotificationManager.assertNotificationShown("John Doe") +} +``` + +### Testing ViewModel + +```kotlin +// feature/sync/presentation/SyncViewModelTest.kt +@Test +fun `startSync shows progress notification`() = runTest { + val fakeNotificationManager = FakeNotificationManager() + val viewModel = SyncViewModel( + syncRepository = fakeSyncRepository, + notificationManager = fakeNotificationManager + ) + + viewModel.startSync() + advanceUntilIdle() + + fakeNotificationManager.assertNotificationShown("Syncing") +} +``` + +## Notification routing + +### Required + +1. **Create notification channels** at app startup (no-op on API < 26) +2. **Check POST_NOTIFICATIONS permission** on API 33+ before showing notifications +3. **Use NotificationCompat** for backward compatibility +4. **Use FLAG_IMMUTABLE** for PendingIntents on API 23+ +5. **Set unique notification IDs** to avoid overwriting notifications +6. **Use foreground notifications** for long-running operations +7. **Provide meaningful icons** for small icon, large icon, and action buttons +8. **Test notifications** on multiple API levels (24, 26, 29, 31, 33, 36) +9. **Use interfaces** for testability in repositories/ViewModels +10. **Handle notification permission** gracefully (don't crash if denied) + +### Forbidden + +1. **Never show notifications without permission check** on API 33+ +2. **Never use FLAG_MUTABLE** for PendingIntents except APIs that require mutable extras (security-sensitive default is immutable) +3. **Never hardcode notification IDs** (use unique IDs or timestamp-based IDs) +4. **Never forget to call startForeground()** within 5 seconds of starting a foreground service +5. **Never use setOngoing(true)** for dismissible notifications +6. **Never rely on notifications** for critical user-facing information (they can be disabled) +7. **Never create channels dynamically** for every notification (create once at startup) +8. **Never show notifications from background** on API 26+ without proper foreground service + +### Notification ID Strategy + +```kotlin +object NotificationIds { + // Fixed IDs for single-instance notifications + const val SYNC_SERVICE = 1001 + const val DOWNLOAD_SERVICE = 1002 + + // Dynamic IDs for multiple notifications + fun forMessage(messageId: String): Int = messageId.hashCode() + fun forDownload(downloadId: String): Int = "download_$downloadId".hashCode() +} +``` + +### Channel Importance Levels + +- `IMPORTANCE_HIGH`: Time-sensitive (messages, calls) - shows heads-up notification +- `IMPORTANCE_DEFAULT`: Standard notifications +- `IMPORTANCE_LOW`: Background operations (downloads, sync) - no sound +- `IMPORTANCE_MIN`: For ongoing foreground services - no sound, no badge + +### PendingIntent Flags + +For API 23+, always use: +```kotlin +PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT +``` + +For mutable PendingIntents (API 31+): +```kotlin +if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT +} else { + PendingIntent.FLAG_UPDATE_CURRENT +} +``` + +## References + +- [Android Notifications Guide](https://developer.android.com/develop/ui/views/notifications) +- [NotificationCompat API](https://developer.android.com/reference/androidx/core/app/NotificationCompat) +- [Notification Channels](https://developer.android.com/develop/ui/views/notifications/channels) +- [Foreground Services](https://developer.android.com/develop/background-work/services/foreground-services) +- [Material Design Notifications](https://m3.material.io/foundations/interaction/notifications/overview) +- [POST_NOTIFICATIONS Permission](https://developer.android.com/develop/ui/views/notifications/notification-permission) diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-performance.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-performance.md new file mode 100644 index 000000000..04d45bebd --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-performance.md @@ -0,0 +1,1357 @@ +# Android Performance + +Required: measure with Macrobenchmark + Baseline Profiles before and after every change to startup, navigation, or list rendering. Track production via Play Vitals + Crashlytics/Sentry. Apply StrictMode guardrails ([android-strictmode.md](/references/android-strictmode.md)). + +## Google Play Vitals and production targets + +[Android vitals](https://developer.android.com/topic/performance/vitals) reports user-perceived crash rate, ANR rate, and slow-start metrics; exceeding bad-behavior thresholds reduces distribution and discovery. + +### Core thresholds (Play Console) + +Google publishes bad-behavior thresholds for **user-perceived** crash rate and ANR rate. Exceeding them can reduce distribution and discovery. Play Console help lists current values; historically the overall phone app bar is often stated around **1.09%** (crash rate) and **0.47%** (ANR rate). Per-device model buckets (and other form factors) can use different numbers. + +| Metric | Typical overall threshold (verify in Play docs) | Notes | +|---------------------------|-------------------------------------------------|----------------------------------------| +| User-perceived crash rate | Often cited around ~1.09% at overall tier | Per phone model and watches may differ | +| User-perceived ANR rate | Often cited around ~0.47% at overall tier | Same: check model-specific rows | + +Use Vitals alongside Firebase Crashlytics or similar to see stack traces and release correlation. + +### Optional: Play Vitals observability (Play Developer Reporting API) + +This is **opt-in**. Use it when you explicitly want **Play Console-grade aggregates** (ANR rate, crash rate, slow start, stuck background wakelocks, error counts, and related metric sets) **automated in your repo and CI** - for example a daily **Slack** (or similar) summary so the team sees health **without opening Play Console**. It does **not** replace in-app crash reporting ([Crashlytics/Sentry](/references/crashlytics.md)); it complements it with **store-aggregated** signals. + +The [Play Developer Reporting API](https://developers.google.com/play/developer/reporting/reference/rest) exposes the same families of metrics as the console: each metric set supports **`get`** (describe the set) and **`query`** with a **`TimelineSpec`** (for example **`DAILY`** aggregation; the API commonly expects timezone such as **`America/Los_Angeles`** for timeline bounds - follow the reference for current rules). + +**Do not** put service account credentials or Reporting API calls inside the **`:app`** module or ship them in the APK. Align with this project's layout by implementing reporting as **Kotlin in `build-logic`** (or a small Gradle plugin module): a **`DefaultTask`** that queries the API and posts formatted output to Slack (Incoming Webhook or Slack Web API). That keeps secrets in **CI/environment variables** and leaves feature modules unchanged - see [modularization.md](/references/modularization.md) and [gradle-setup.md](/references/gradle-setup.md). + +**Authentication:** use a Google Cloud **service account** with access to your Play Developer account; load JSON from an **environment variable** or CI secret (never commit keys). Request OAuth scope: + +`https://www.googleapis.com/auth/playdeveloperreporting` + +**Dependency:** add the generated client **`com.google.apis:google-api-services-playdeveloperreporting`** for API version **`v1beta1`**, with the revision pinned in **`gradle/libs.versions.toml`** (or `assets/libs.versions.toml.template` when creating a new project from this repo). + +**Implementation sketch:** call **`query`** on the relevant metric set (for example crash rate, ANR rate) with your timeline and metric names; map **`MetricsRow`** results into small **Kotlin data classes** per set; optionally compare values to the **Core thresholds** table above for a simple green/yellow/red summary; format markdown or blocks for Slack. + +#### Example (build-logic, schematic) + +Keep all of this **outside** `:app` (for example under `build-logic/convention/` or a dedicated `build-logic/play-vitals/` module). Pin the client in the version catalog. **`build-logic/convention`** already includes **`kotlinx-coroutines-core`** for **`PlayVitalsReportingTask`**; add **`suspend`** + **`withContext`** usage in **`PlayVitalsRepository`** as shown below. + +Version pins live in **`gradle/libs.versions.toml`**. Check [`assets/libs.versions.toml.template`](../assets/libs.versions.toml.template): **`googlePlayDeveloperReporting`**, **`googleAuthLibraryOauth2Http`**, and the **`google-api-services-playdeveloperreporting`** / **`google-auth-library-oauth2-http`** library aliases, then bump **`googlePlayDeveloperReporting`** when you adopt a newer generated client. + +`build-logic` module `build.gradle.kts` - add these when you implement **`PlayVitalsRepository`** ( **`kotlinx-coroutines-core`** is already there for **`PlayVitalsReportingTask`**): + +```kotlin +dependencies { + implementation(libs.google.api.services.playdeveloperreporting) + implementation(libs.google.auth.library.oauth2.http) +} +``` + +Domain-style models and a small repository: **suspend** functions perform HTTP on **`Dispatchers.IO`** (testable without a Gradle task). The generated client's **`execute()`** stays inside `withContext`. + +```kotlin +// e.g. build-logic/.../reporting/AnrRateSummary.kt +data class AnrRateSummary( + val dailyPercent: Float, + val weighted7dPercent: Float, + val weighted28dPercent: Float, +) + +// e.g. build-logic/.../reporting/PlayVitalsRepository.kt +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport +import com.google.api.client.http.HttpRequestInitializer +import com.google.api.client.json.gson.GsonFactory +import com.google.api.services.playdeveloperreporting.Playdeveloperreporting +import com.google.api.services.playdeveloperreporting.model.GooglePlayDeveloperReportingV1beta1QueryAnrRateMetricSetRequest +import com.google.auth.http.HttpCredentialsAdapter +import com.google.auth.oauth2.GoogleCredentials +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private val PLAY_REPORTING_SCOPE = "https://www.googleapis.com/auth/playdeveloperreporting" + +class PlayVitalsRepository( + private val appName: String, // e.g. "apps/com.example.app" - resource name prefix for metric sets + private val serviceAccountJson: String, +) { + private val transport = GoogleNetHttpTransport.newTrustedTransport() + private val jsonFactory = GsonFactory.getDefaultInstance() + + private val http: HttpRequestInitializer = + HttpCredentialsAdapter( + GoogleCredentials + .fromStream(serviceAccountJson.byteInputStream()) + .createScoped(PLAY_REPORTING_SCOPE), + ) + + private val api: Playdeveloperreporting by lazy { + Playdeveloperreporting.Builder(transport, jsonFactory, http) + .setApplicationName("play-vitals-reporting") + .build() + } + + /** Returns null if the API returns no row (freshness lag, bad window) or on transport failure, do not fail the Gradle task. */ + suspend fun queryAnrRates(request: GooglePlayDeveloperReportingV1beta1QueryAnrRateMetricSetRequest): AnrRateSummary? = + withContext(Dispatchers.IO) { + val name = "$appName/anrRateMetricSet" + val row = runCatching { + api.vitals().anrrate().query(name, request).execute().rows?.firstOrNull() + }.getOrElse { return@withContext null } + ?: return@withContext null + val byMetric = row.metrics.associate { metric -> + metric.metric to (metric.decimalValue?.value?.toFloat()?.times(100f) ?: Float.NaN) + } + AnrRateSummary( + dailyPercent = byMetric["anrRate"] ?: Float.NaN, + weighted7dPercent = byMetric["anrRate7dUserWeighted"] ?: Float.NaN, + weighted28dPercent = byMetric["anrRate28dUserWeighted"] ?: Float.NaN, + ) + } +} +``` + +This runs only on the **build machine** (Gradle), not in the shipped app. Throwing **`error()`** / letting exceptions propagate would still **fail the task and can fail CI**. For optional health reporting, prefer **nullable / `Result`**, **`runCatching`**, Gradle **`logger.lifecycle` / `logger.warn`**, post "metrics unavailable" to Slack, and **return from `@TaskAction` without rethrowing** so the job exits successfully unless you explicitly want a red pipeline for misconfiguration. + +Build a **`TimelineSpec`** (aggregation period, start/end in **`America/Los_Angeles`** as **`GoogleTypeDateTime`**) per the REST reference; reuse the same pattern for **`crashRateMetricSet`**, **`slowStartRateMetricSet`**, etc., changing the vitals client path and metric names. + +**Gradle task entry point:** the canonical task body lives in **[`PlayVitalsReportingTask.kt`](../assets/convention/PlayVitalsReportingTask.kt)** - env check, then **`runBlocking { ... }`** with commented placeholders for **`PlayVitalsRepository`**, request, and Slack. Keep HTTP inside the repository's **`withContext(Dispatchers.IO)`** (avoid **`runBlocking(Dispatchers.IO)`** *and* another **`withContext(Dispatchers.IO)`** - pick one outer scope). Keep **`@TaskAction`** free of configuration-time work. **`build-logic/convention`** already depends on **`kotlinx-coroutines-core`** for **`runBlocking`**; add Reporting API artifacts when you uncomment the repository. + +**Registration:** sources ship under **`assets/convention/`** ([`PlayVitalsReportingConventionPlugin.kt`](../assets/convention/PlayVitalsReportingConventionPlugin.kt), [`PlayVitalsReportingTask.kt`](../assets/convention/PlayVitalsReportingTask.kt)), registered in [`assets/convention/build.gradle.kts`](../assets/convention/build.gradle.kts). Add catalog plugin **`app-play-vitals`** from [`assets/libs.versions.toml.template`](../assets/libs.versions.toml.template) to **`gradle/libs.versions.toml`**. After you copy convention sources into **`build-logic`**, add **`alias(libs.plugins.app.play.vitals)`** to the **`plugins { }`** block in the **root** **`build.gradle.kts`**. Apply it there **only** (not in **`app`** or feature modules). For where to copy files, how the root block should look, and CI, see [gradle-setup.md](/references/gradle-setup.md) and [QUICK_REFERENCE.md](../assets/convention/QUICK_REFERENCE.md). + +**CI/CD:** schedule a job (for example nightly) that runs `./gradlew ` and injects secrets at runtime: service account JSON, Slack token or webhook URL, and the **`apps/...`** resource name for the app you report on. + +**Kotlin and coroutines:** Gradle tasks run on the build JVM; I/O belongs in **`@TaskAction`** (or a worker). Use **`suspend`** + **`withContext(Dispatchers.IO)`** in a dedicated class for clarity and tests; the task only **`runBlocking { … }`**. Avoid duplicate **`Dispatchers.IO`** if the task already uses **`runBlocking(Dispatchers.IO)`**. See [kotlin-patterns.md](/references/kotlin-patterns.md) and [coroutines-patterns.md](/references/coroutines-patterns.md). Avoid heavy work during **configuration** phase. + +### Startup time (user experience) + +Targets below are practical goals for **cold / warm / hot** start. If cold start routinely exceeds about **2 seconds** on mid-range hardware, show a splash or inline progress so the user sees feedback ([App Startup & Initialization](#app-startup--initialization)). + +| Start type | Target (typical) | Investigate if worse than (rule of thumb) | +|------------|------------------|-------------------------------------------| +| Cold | Under ~1 s | ~2 s without progress UI | +| Warm | Under ~500 ms | ~1 s | +| Hot | Under ~100 ms | ~500 ms | + +Align measurement with **TTID / TTFD** and Macrobenchmark `StartupTimingMetric()` (see below). + +### Frame time and jank + +Rendering should stay within the display's frame budget: + +| Display | Frame budget (approx.) | +|---------|------------------------| +| 60 Hz | ~16.7 ms per frame | +| 90 Hz | ~11.1 ms per frame | +| 120 Hz | ~8.3 ms per frame | + +**Slow frames** exceed the budget; **frozen frames** are long stalls (typically hundreds of ms or more). Investigate with `FrameTimingMetric()`, [Perfetto (system traces)](#perfetto-system-traces), and Android Studio profilers. + +### Background work and battery + +Required: +- Use **WorkManager** for deferrable background work; foreground service only with a user-visible notification. +- Design for **Doze** and **App Standby**: batch work; use FCM for push. +- Release **WakeLocks** with timeouts; never hold partial wake locks across idle. + +StrictMode and main-thread guardrails: [android-strictmode.md](/references/android-strictmode.md). + +## Benchmark + +Required: Macrobenchmark for end-to-end journeys (startup, scrolling, navigation). Microbenchmark for isolated code paths only. + +### Macrobenchmark (Compose) + +Use when: +- Investigating cold/warm start regressions. +- Measuring Compose navigation, list scrolling, or animation jank. +- Producing repeatable numbers for CI gating. + +Module setup: see [gradle-setup.md](/references/gradle-setup.md) → "Benchmark Module (Optional)". + +#### Compose Macrobenchmark Example +```kotlin +@RunWith(AndroidJUnit4::class) +class AuthStartupBenchmark { + @get:Rule + val benchmarkRule = MacrobenchmarkRule() + + @Test + fun coldStart() = benchmarkRule.measureRepeated( + packageName = "com.example.app", + metrics = listOf(StartupTimingMetric()), + compilationMode = CompilationMode.Partial(), + iterations = 5, + startupMode = StartupMode.COLD + ) { + pressHome() + startActivityAndWait() + } +} +``` + +#### Macrobenchmark rules + +- Use `CompilationMode.Partial()` to approximate Baseline Profile behavior when comparing changes. +- Use `StartupMode.COLD/WARM/HOT` to measure the scenario you care about. +- Keep actions in `measureRepeated` focused and deterministic (e.g., navigate to one screen, scroll one list). +- Wait for UI idleness with `device.waitForIdle()` between steps when needed. +- Use `FrameTimingMetric()` when measuring Compose list scroll or navigation jank. + +#### Common Metrics +- `StartupTimingMetric()` for cold/warm start. +- `FrameTimingMetric()` for scrolling/jank. +- `MemoryUsageMetric()` for memory regressions. + +#### Running Benchmarks +Use a **physical device** (emulators add noise). Disable system animations: +```bash +adb shell settings put global animator_duration_scale 0 +adb shell settings put global transition_animation_scale 0 +adb shell settings put global window_animation_scale 0 +``` + +Run all benchmarks: +```bash +./gradlew :benchmark:connectedCheck +``` + +Run a single benchmark class: +```bash +./gradlew :benchmark:connectedAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.class=com.example.benchmark.AuthStartupBenchmark +``` + +#### Reports & Artifacts +Results are generated per device: +- `benchmark/build/outputs/connected_android_test_additional_output/` (JSON results) +- `benchmark/build/reports/androidTests/connected/` (HTML summary) + +Use these in CI to detect regressions and track changes over time. + +#### Custom System Tracing + +Required: wrap app-level critical sections in `trace { }`. Macrobenchmark traces alone rarely surface in-app hotspots. + +Use Tracing 2.0 (`tracing-wire-android`) for low overhead and Coroutine context propagation: +```kotlin +// app/build.gradle.kts +dependencies { + implementation(libs.androidx.tracing.wire) // or libs.androidx.tracing +} +``` + +**Usage:** + +Wrap the code you want to measure in a `trace` block: +```kotlin +import androidx.tracing.trace + +fun processImage() { + trace("processImage") { + // Your work here will appear as a custom section in the system trace + loadImage() + sharpen() + } +} +``` + +For Kotlin Coroutines, Tracing 2.0 supports context propagation to correctly visualize suspended and resumed tasks: +```kotlin +suspend fun taskOne(tracer: Tracer) { + tracer.traceCoroutine(category = "main", "taskOne") { + delay(100L) + } +} +``` + +Custom `trace` / `traceCoroutine` slices from [Custom System Tracing](#custom-system-tracing) show up in system traces opened in Perfetto-capable viewers. + +### Perfetto (system traces) + +Required: treat scheduling, Binder/IPC waits, I/O blocks, and frame pipeline timing as **trace-backed** claims; Kotlin-only reasoning does not substitute for timeline evidence. + +| Symptom or goal | Collection path | +|------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Jank, missed frame deadlines, UI latency | System trace with Frame Timeline context: Android Studio Profiler, Macrobenchmark trace output, or headless `perfetto` / SDK capture ([Android Perfetto](https://developer.android.com/tools/perfetto)). | +| Repeatable startup or scroll regressions | Macrobenchmark metrics plus trace artifacts; align slice names with `trace {}` / `traceCoroutine` strings in app code. | +| GPU-focused render stages / counters | Android GPU Inspector (Perfetto-backed); follow AGI docs for capture scope. | +| Programmatic on-device capture | `ProfilingManager` and related Android SDK APIs when the task requires SDK-driven sessions ([Android Perfetto](https://developer.android.com/tools/perfetto)). | +| User supplies `bugreport.zip` or a `.perfetto-trace` | User opens the artifact in [Perfetto UI](https://perfetto.dev/docs/visualization/perfetto-ui); routing and tool choice: [How do I start using Perfetto?](https://perfetto.dev/docs/getting-started/start-using-perfetto). | + +**Required:** + +- Add or extend `androidx.tracing` slices with **stable, grep-friendly names** before recommending thread splits, dispatcher changes, or Binder-heavy refactors when the symptom is jank, frozen frames, or ANRs. +- When the user has **no** trace and **no** benchmark numbers: output a minimal repro (physical device, animation scales off, one Macrobenchmark scenario or one manual capture) and the benchmark output paths from [Reports & Artifacts](#reports--artifacts); do not assert root cause from static code alone. +- When the user pastes **text** from a trace viewer (slice names, durations, thread labels): map those names to code paths by identifier; when they attach only a binary trace or bugreport without description, state that timeline truth needs local inspection in Perfetto UI (or trace processor output they paste) and ask for named slices or exported text. + +**Forbidden when:** + +- Stating frame timings, Binder wait durations, or scheduler gaps without a trace, benchmark metric, or user-supplied measurement text. +- Treating Logcat alone as proof of frame scheduling or cross-process latency for jank investigations. + +Perfetto overview, data sources, and analysis stack: [perfetto.dev/docs](https://perfetto.dev/docs/). Cookbook-style Android trace workflows live under that doc tree (Getting Started → Cookbooks). For role-based entry (app dev vs platform vs other), use [How do I start using Perfetto?](https://perfetto.dev/docs/getting-started/start-using-perfetto). + +### Startup Performance Metrics (TTID & TTFD) + +Android provides two key metrics for measuring app startup performance: + +#### Time to Initial Display (TTID) +The time until the first frame is drawn. This is automatically measured by the system and reported in Logcat. + +#### Time to Full Display (TTFD) +The time until your app is fully interactive with all critical content loaded. You must explicitly call `reportFullyDrawn()` to measure this. + +#### ReportDrawn APIs (Compose) + +Use `androidx.activity.compose` APIs to declaratively report when your Compose UI is ready: + +```kotlin +@Composable +fun UserListRoute( + viewModel: UserListViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + // Report fully drawn when data is loaded and UI is ready + ReportDrawnWhen { uiState is UserListUiState.Success } + + UserListScreen(uiState = uiState) +} +``` + +**Available APIs:** + +1. **ReportDrawn()** - Reports immediately (use when no async loading needed) +```kotlin +@Composable +fun StaticScreen() { + ReportDrawn() // Screen is immediately ready + Text("Welcome") +} +``` + +2. **ReportDrawnWhen(predicate)** - Reports when condition is true +```kotlin +@Composable +fun DataScreen(viewModel: DataViewModel) { + val isDataLoaded by viewModel.isDataLoaded.collectAsStateWithLifecycle() + + ReportDrawnWhen { isDataLoaded } + + if (isDataLoaded) { + DataContent() + } else { + LoadingIndicator() + } +} +``` + +3. **ReportDrawnAfter { }** - Reports after suspending block completes +```kotlin +@Composable +fun AsyncScreen() { + ReportDrawnAfter { + // Suspend until critical data is ready + awaitCriticalData() + } + + ScreenContent() +} +``` + +#### `ReportDrawn*` rules + +- **Call once per screen**: Multiple `ReportDrawnWhen` calls become no-ops after the first reports +- **Handle error states**: Report even on errors to avoid blocking metrics +```kotlin +ReportDrawnWhen { + uiState is UserListUiState.Success || uiState is UserListUiState.Error +} +``` +- **Don't wait for everything**: Report when the primary content is visible, not when all images/ads load +- **Test with Macrobenchmark**: Combine with `StartupTimingMetric()` to measure TTFD in benchmarks + +#### Viewing TTFD in Logcat + +After calling `reportFullyDrawn()` (or via ReportDrawn APIs), look for: +``` +ActivityTaskManager: Fully drawn com.example.app/.MainActivity: +850ms +``` + +This metric is crucial for understanding real user experience beyond initial frame rendering. + +### Baseline Profiles + +Baseline Profiles improve app startup and runtime performance by pre-compiling critical code paths. They are automatically generated and included in release builds. + +#### Use baseline profiles when: + +- Cold start time must drop (typical gains 10–30%). +- Critical journeys (scroll, navigation, animation) need AOT coverage. +- High-traffic screens show persistent jank without profiles. + +#### Module Setup + +Create a `:baselineprofile` test module using pure Gradle configuration (no GUI templates needed). + +`baselineprofile/build.gradle.kts`: +```kotlin +plugins { + alias(libs.plugins.android.test) + alias(libs.plugins.androidx.baselineprofile) +} + +android { + namespace = "com.example.baselineprofile" + compileSdk { + version = release(libs.findVersion("compileSdk").get().toInt()) + } + + targetProjectPath = ":app" + + defaultConfig { + minSdk = libs.findVersion("minSdk").get().toString().toInt() + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + testOptions.managedDevices.localDevices { + create("pixel6Api31") { + device = "Pixel 6" + apiLevel = 31 + systemImageSource = "aosp" + } + } +} + +dependencies { + implementation(libs.androidx.test.ext.junit) + implementation(libs.androidx.test.espresso.core) + implementation(libs.androidx.test.uiautomator) + implementation(libs.androidx.benchmark.macro.junit4) +} + +baselineProfile { + managedDevices += "pixel6Api31" + useConnectedDevices = false +} +``` + +Update `app/build.gradle.kts`: +```kotlin +plugins { + alias(libs.plugins.app.android.application) + alias(libs.plugins.app.android.application.compose) + alias(libs.plugins.app.android.application.baseline) + alias(libs.plugins.app.hilt) +} + +dependencies { + baselineProfile(project(":baselineprofile")) +} +``` + +The `app.android.application.baseline` convention plugin (from `assets/convention/AndroidApplicationBaselineProfileConventionPlugin.kt`) automatically applies the `androidx.baselineprofile` plugin and configures it for your app module. + +#### Define the Baseline Profile Generator + +`baselineprofile/src/main/java/.../BaselineProfileGenerator.kt`: +```kotlin +@RunWith(AndroidJUnit4::class) +class BaselineProfileGenerator { + @get:Rule + val rule = BaselineProfileRule() + + @Test + fun generate() = rule.collect( + packageName = "com.example.app", + includeInStartupProfile = true, + profileBlock = { + startActivityAndWait() + + // Add critical user journeys here + device.wait(Until.hasObject(By.res("auth_form")), 5000) + + // Navigate through key screens + device.findObject(By.text("Login")).click() + device.waitForIdle() + } + ) +} +``` + +#### Generate the Baseline Profile + +Run the generation task: +```bash +./gradlew :app:generateReleaseBaselineProfile +``` + +The generated profile is automatically placed in `app/src/release/generated/baselineProfiles/baseline-prof.txt` and included in release builds. + +#### Benchmark the Baseline Profile + +Compare performance with and without Baseline Profiles: + +```kotlin +@RunWith(AndroidJUnit4::class) +class StartupBenchmark { + @get:Rule + val benchmarkRule = MacrobenchmarkRule() + + @Test + fun startupNoCompilation() = startup(CompilationMode.None()) + + @Test + fun startupWithBaselineProfiles() = startup( + CompilationMode.Partial( + baselineProfileMode = BaselineProfileMode.Require + ) + ) + + private fun startup(compilationMode: CompilationMode) = + benchmarkRule.measureRepeated( + packageName = "com.example.app", + metrics = listOf(StartupTimingMetric()), + compilationMode = compilationMode, + iterations = 10, + startupMode = StartupMode.COLD + ) { + pressHome() + startActivityAndWait() + } +} +``` + +#### Key Points +- Baseline Profiles are only installed in release builds. +- Use physical devices or GMDs with `systemImageSource = "aosp"`. +- Update profiles when adding new features or changing critical paths. +- Include both startup and runtime journeys (scrolling, navigation) for best results. + +#### ProfileInstaller + +Required: add `androidx.profileinstaller` so ART compiles Baseline Profiles on first launch (mandatory for non-Play distribution; redundant only when Play Store cloud profiles cover every install path). + +```kotlin +// app/build.gradle.kts +dependencies { + implementation(libs.androidx.profileinstaller) +} +``` + +## Compose Stability Validation (Optional) + +Use [Compose Stability Analyzer](https://github.com/skydoves/compose-stability-analyzer) for CI gating on composable skippability. + +### IDE Plugin (Optional) + +Install: **Settings** → **Plugins** → **Marketplace** → "Compose Stability Analyzer". Surfaces gutter icons, hover tooltips, inline parameter stability hints, and inspections. + +### Gradle Plugin for CI/CD + +Setup: [gradle-setup.md](/references/gradle-setup.md) → "Compose Stability Analyzer (Optional)". + +#### Generate Baseline + +Create a snapshot of current composables' stability: +```bash +./gradlew :app:stabilityDump +``` + +Commit the generated `.stability` file to version control. + +#### Validate in CI + +Check for stability changes: +```bash +./gradlew :app:stabilityCheck +``` + +The build fails if composable stability regresses, preventing performance issues from reaching production. + +#### GitHub Actions Example + +```yaml +stability_check: + name: Compose Stability Check + runs-on: ubuntu-latest + needs: build + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: 21 + - name: Stability Check + run: ./gradlew stabilityCheck +``` + +## CPU Optimization + +Required: + +1. **Hoist invariants out of loops.** +```kotlin +// Bad +for (i in 0 until items.size) { + process(items[i]) +} + +// Good +items.forEach(::process) +``` + +2. **Use `StringBuilder` for any concatenation in a loop.** +```kotlin +// Bad +var result = "" +for (i in 1..1000) { + result += "Item $i\n" +} + +// Good +val result = StringBuilder() +for (i in 1..1000) { + result.append("Item $i\n") +} +``` + +3. **Cache compiled `Regex` instances.** +```kotlin +// Bad +fun validateEmail(email: String): Boolean = + email.matches(Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$")) + +// Good +private val EMAIL_REGEX = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$") + +fun validateEmail(email: String): Boolean = email.matches(EMAIL_REGEX) +``` + +## Battery Optimization + +Required: + +1. **Always release `WakeLock` or acquire with a timeout.** +```kotlin +// Bad +wakeLock.acquire() + +// Good +wakeLock.acquire(10 * 60 * 1000L) +``` + +2. **Use `PRIORITY_BALANCED_POWER_ACCURACY` and intervals ≥ 30 s for foreground location.** +```kotlin +// Bad +locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 1000L, 0f, listener) + +// Good +val locationRequest = LocationRequest.create().apply { + interval = 60000 + fastestInterval = 30000 + priority = LocationRequest.PRIORITY_BALANCED_POWER_ACCURACY +} +``` + +### Excessive partial wake locks (Play Vitals core metric) + +A user session is excessive when cumulative non-exempt wake locks exceed **2 hours in a 24-hour period**. The bad-behavior threshold trips when **>5% of sessions over 28 days** are excessive (enforced March 1, 2026). Crossing the threshold can warn users on the store listing and exclude the app from discovery surfaces. Inspect tag-level P90/P99 durations on the [Excessive partial wake locks dashboard](https://play.google.com/console/developers/app/vitals/metrics/details?metric=EXCESSIVE_BACKGROUND_WAKELOCKS&days=28); investigate any tag with P90/P99 > 60 minutes. Definition: [Android vitals - Excessive wake locks](https://developer.android.com/topic/performance/vitals/excessive-wakelock). + +Exempted: system-held wake locks for audio playback, location update callbacks, and user-initiated data transfer. + +Forbidden: acquiring a manual wake lock alongside an API that already wakes the CPU. + +#### Use case to substitute matrix + +Replace manual partial wake locks with the API listed for each use case. See [Choose the right API to keep the device awake](https://developer.android.com/develop/background-work/background-tasks/awake) for the platform decision flow. + +| Use case | Substitute | +|-----------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| User-initiated upload or download | [UIDT API](https://developer.android.com/develop/background-work/background-tasks/uidt) (exempted from the metric). | +| One-time or periodic background sync | [WorkManager](https://developer.android.com/develop/background-work/background-tasks/persistent); observe `WorkInfo.stopReason`. | +| Location callbacks | `FusedLocationProviderClient` / `LocationManager` - the system holds the brief wake lock for the callback. | +| Caching location data for later upload | Persist in memory or local storage; process via WorkManager. No separate wake lock. | +| High-frequency sensor monitoring | `SensorManager.registerListener(..., samplingPeriodUs, maxReportLatencyUs)` with `maxReportLatencyUs >= 30_000_000` (30 s) for batching. | +| Step or distance tracking | [Recording API](https://developer.android.com/health-and-fitness/guides/recording-api) or [Health Connect](https://developer.android.com/health-and-fitness/health-connect/features/steps). | +| Bluetooth pairing or background communication | [CompanionDeviceManager](https://developer.android.com/develop/connectivity/bluetooth/companion-device-pairing) and [BLE background guidance](https://developer.android.com/develop/connectivity/bluetooth/ble/background). Manual wake lock only for the duration of activity. | +| Remote messaging from a server | FCM; schedule an [expedited worker](https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work#expedited) if extra processing is required. | +| Network socket waiting for packets | Acquire a wake lock only **after** a packet arrives, never while waiting on `readChannel.readRemaining(...)`. | + +#### Diagnose stuck workers + +Stuck workers (timing out, retried in a loop) hold wake locks under `WorkManager` and `JobScheduler` tags. Observe `WorkInfo.stopReason` to detect them; high `STOP_REASON_TIMEOUT` counts mean a worker is misconfigured. + +```kotlin +workManager.getWorkInfoByIdFlow(syncWorker.id) + .collect { workInfo -> + if (workInfo != null) { + val stopReason = workInfo.stopReason + logStopReason(syncWorker.id, stopReason) + } + } +``` + +#### Sensor batching + +Set `maxReportLatencyUs` so the OS delivers buffered samples on its own wake schedule instead of the app polling. + +```kotlin +val accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) +sensorManager.registerListener( + listener, + accelerometer, + samplingPeriodUs, + maxReportLatencyUs, // >= 30_000_000 (30 s) keeps tag duration under the excessive threshold +) +``` + +#### Network socket wake-lock placement + +Hold a wake lock around packet processing only, never around the read loop. + +```kotlin +val readChannel = socket.openReadChannel() +while (!readChannel.isClosedForRead) { + // CPU may sleep here; the radio's hardware interrupt wakes it on packet arrival. + val packet = readChannel.readRemaining(1024) + if (!packet.isEmpty) { + performWorkWithWakeLock { + processPacket(packet.readBytes()) + } + } +} +``` + +Identifying the offending wake lock by tag (especially when an SDK created it): cross-reference the dashboard tag against [Identify wake locks created by other APIs](https://developer.android.com/develop/background-work/background-tasks/awake/wakelock/identify-wls). Capture a system trace via [Perfetto](https://developer.android.com/topic/performance/tracing/on-device) when the tag is unknown. + +## Network Performance Optimization + +Required: never run network on the main thread; cache responses; batch requests; enable HTTP/2. + +1. **OkHttp/Retrofit with `Cache`.** +```kotlin +val cacheSize = 10 * 1024 * 1024L // 10 MB +val cache = Cache(context.cacheDir, cacheSize) + +val okHttpClient = OkHttpClient.Builder() + .cache(cache) + .addInterceptor { chain -> + var request = chain.request() + request = if (hasNetwork(context)) { + request.newBuilder().header("Cache-Control", "public, max-age=60").build() + } else { + request.newBuilder().header("Cache-Control", "public, only-if-cached, max-stale=86400").build() + } + chain.proceed(request) + } + .build() +``` + +2. **Compress Images Before Upload**: Compress images locally before sending them to the server. +```kotlin +bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream) // 80% quality +``` + +3. **Batch Network Requests**: Instead of making 100 separate network calls for individual items, make a single batch request. + +4. **Enable HTTP/2**: HTTP/2 multiplexes requests over a single connection, making it faster. +```kotlin +val okHttpClient = OkHttpClient.Builder() + .protocols(listOf(Protocol.HTTP_2, Protocol.HTTP_1_1)) + .build() +``` + +## Image Optimization + +Required: + +1. **Coil for all network images.** Forbidden: direct `BitmapFactory.decodeStream` from network. +```kotlin +imageView.load(imageUrl) { + crossfade(true) + placeholder(R.drawable.placeholder) +} +``` + +2. **Decode at display size.** Never decode a 4000×3000 bitmap into a 200 dp view; let Coil size it. + +3. **Format selection:** JPEG for photos, PNG for transparent icons, **WebP** for everything else (smaller than JPEG, supports transparency). + +4. **Vector drawables for icons.** Forbidden: shipping per-density PNG sets when an `.xml` vector exists. + +## APK Size Optimization + +Required: + +1. **Enable R8.** `isMinifyEnabled = true` and `isShrinkResources = true` on every release build. +2. **Ship AAB, not APK.** Play Store generates per-device splits. +3. **Filter resources via `resConfigs`** to ship only supported languages. +```kotlin +android { + defaultConfig { + resConfigs("en", "es") // Only keep English and Spanish + } +} +``` +4. **Convert PNG → WebP** wherever it preserves quality. +5. **Filter NDK ABIs** to common architectures. +```kotlin +android { + defaultConfig { + ndk { + abiFilters.addAll(listOf("armeabi-v7a", "arm64-v8a")) + } + } +} +``` + +## App Startup & Initialization + +Required: control component initialization explicitly. Forbidden: per-library `ContentProvider` auto-initialization. Use `androidx.startup` or lazy initialization. + +### ContentProvider Anti-Pattern + +Library `ContentProvider.onCreate()` runs before `Application.onCreate()` on the main thread; each one adds cold-start cost. Disable per-library auto-initialization and route through `androidx.startup`. + +**Disable a library's auto-initialization** (e.g., WorkManager): +```xml + + + + + +``` + +### App Startup Library + +Use `androidx.startup:startup-runtime` to consolidate initialization into a single shared ContentProvider with explicit dependency ordering. + +**1. Implement an `Initializer`:** +```kotlin +// core/common/src/main/kotlin/com/example/core/startup/TimberInitializer.kt +import android.content.Context +import androidx.startup.Initializer +import timber.log.Timber + +class TimberInitializer : Initializer { + override fun create(context: Context) { + if (BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()) + } + } + + override fun dependencies(): List>> = emptyList() +} +``` + +**2. Initializer with dependencies:** +```kotlin +// core/common/src/main/kotlin/com/example/core/startup/CrashReporterInitializer.kt +class CrashReporterInitializer : Initializer { + override fun create(context: Context) { + CrashReporter.init(context) + } + + override fun dependencies(): List>> = listOf( + TimberInitializer::class.java + ) +} +``` + +**3. Register in AndroidManifest:** +```xml + + + + + +``` + +**4. Lazy initialization (on-demand):** + +Remove the `` entry from the manifest and initialize manually when needed: +```kotlin +AppInitializer.getInstance(context) + .initializeComponent(CrashReporterInitializer::class.java) +``` + +### Lazy Initialization Strategies + +Defer non-essential work until after the first frame is drawn: + +```kotlin +// In Application or MainActivity +class MyApplication : Application() { + override fun onCreate() { + super.onCreate() + + // Critical path only - keep minimal + // App Startup handles TimberInitializer, CrashReporterInitializer + + // Defer non-critical initialization + ProcessLifecycleOwner.get().lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + // First time app becomes visible - initialize non-critical components + initializeNonCritical() + owner.lifecycle.removeObserver(this) + } + } + ) + } + + private fun initializeNonCritical() { + // Analytics, feature flags, prefetch, etc. + } +} +``` + +**In Compose - defer heavy content until first frame:** +```kotlin +@Composable +fun DeferredContent(content: @Composable () -> Unit) { + var shouldLoad by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + // Yield to let the first frame draw + withContext(Dispatchers.Main) { + shouldLoad = true + } + } + + if (shouldLoad) { + content() + } +} +``` + +**What to initialize eagerly vs lazily:** + +| Timing | Components | +|---------------------|-----------------------------------------------------------| +| Eager (App Startup) | Crash reporter, logging, StrictMode | +| After first frame | Analytics, feature flags, remote config | +| On demand | Image loader, ML models, database migrations, WorkManager | + +### Splash Screen + +Required: Add `androidx.core:core-splashscreen` to `:app` (`implementation(libs.androidx.core.splashscreen)`); pin the version in `gradle/libs.versions.toml` using `assets/libs.versions.toml.template` (`splashscreen`). Module wiring: [gradle-setup.md](/references/gradle-setup.md). + +Required: Call `installSplashScreen()` on the process launcher activity before `super.onCreate()` so Android 12+ system splash and compat pre-12 share one theme-backed path. Attribute list and platform rules: [Splash screen](https://developer.android.com/develop/ui/views/launch/splash-screen). Legacy `windowBackground` themes and dedicated splash activities: [migration.md](/references/migration.md) → **Legacy splash to Splash Screen API**. + +**Icon mask:** Size `windowSplashScreenAnimatedIcon` per [Splash screen](https://developer.android.com/develop/ui/views/launch/splash-screen): with `Theme.SplashScreen.IconBackground`, use **240×240 dp** artwork inside a **160 dp** diameter circle; with `Theme.SplashScreen` only, **288×288 dp** inside **192 dp**. Re-read the live doc when bumping `compileSdk`. On API 31+, check that doc for optional `splashScreenIconSize`. + +Use `Theme.SplashScreen.IconBackground` when the foreground needs a solid circular plate behind transparent artwork. + +| Setup | Behavior | +|---------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------| +| `core-splashscreen`, API 30 and below | Library-backed splash; same theme attributes the compat library applies on that level. | +| API 31+ | System splash; animated icon and `windowSplashScreenAnimationDuration` (milliseconds, max **1000** on API 31+) follow platform rules. | + +**Required: `onCreate` call order** + +`installSplashScreen()` → `super.onCreate()` → `setKeepOnScreenCondition { }` (if used) → `enableEdgeToEdge()` → `setContent { }`. The splash window is system-drawn until handoff; first app frames still need inset handling (`Scaffold` / `innerPadding` - see [migration.md](/references/migration.md) Edge-to-Edge, `references/compose-patterns.md`). + +Splash dismissal merges system-controlled minimum visibility, `windowSplashScreenAnimationDuration` when set (API 31+, max 1000 ms), and `setKeepOnScreenCondition` when registered. Copy the exact interaction from [Splash screen](https://developer.android.com/develop/ui/views/launch/splash-screen) when auditing a new `compileSdk`. + +Test the launcher activity on **minSdk**, on **API 31+**, on at least one gesture or default edge-to-edge configuration, and on foldables **when** large-screen layouts ship. + +After handoff to Compose, call `ReportDrawn*` so metrics track full display when primary UI is ready, not only splash dismissal ([Startup Performance Metrics (TTID & TTFD)](#startup-performance-metrics-ttid--ttfd)). + +**Forbidden when:** Holding the splash for open-ended network work; dismiss for local readiness and use in-app placeholders for long remote work ([migration.md](/references/migration.md) → **Legacy splash to Splash Screen API**). + +**Wrong:** + +`setContent` omits `AppTheme` / root navigation while `isLoading` is true, so no composition subtree mounts under the splash. + +**Correct:** + +Always mount `AppTheme` and a composable root; branch **inside** the tree for loading vs main UI (or rely only on `setKeepOnScreenCondition` without stripping the root). + +**Required: splash theme (values)** + +```xml + + + + +``` + +**Required: manifest (launcher activity)** + +```xml + + + + +``` + +**Required: launcher `Activity` (Compose)** + +```kotlin +// app/src/main/kotlin/com/example/app/MainActivity.kt +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen + +class MainActivity : ComponentActivity() { + private val viewModel: MainViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + val splashScreen = installSplashScreen() + super.onCreate(savedInstanceState) + + splashScreen.setKeepOnScreenCondition { + viewModel.isLoading.value + } + + enableEdgeToEdge() + + setContent { + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + AppTheme { + if (isLoading) { + Box(Modifier.fillMaxSize()) + } else { + AppNavigation() + } + } + } + } +} +``` + +**ViewModel driving `setKeepOnScreenCondition`** + +```kotlin +@HiltViewModel +class MainViewModel @Inject constructor( + private val authRepository: AuthRepository +) : ViewModel() { + private val _isLoading = MutableStateFlow(true) + val isLoading: StateFlow = _isLoading.asStateFlow() + + init { + viewModelScope.launch { + authRepository.isAuthenticated() + _isLoading.value = false + } + } +} +``` + +**Use when:** custom exit animation from the splash surface to the first frame - `setOnExitAnimationListener { splashScreenView -> ... }`; end with `splashScreenView.remove()` after the animator finishes. API and sample: [Splash screen](https://developer.android.com/develop/ui/views/launch/splash-screen). + +**Required:** + +- Call `installSplashScreen()` before `super.onCreate()`. +- `setKeepOnScreenCondition` runs on the main thread before each draw; return only from cheap reads (multiple primitive flag reads allowed). Forbidden: I/O, network, heavy work, allocation, or `runBlocking` inside the lambda. +- `setKeepOnScreenCondition` absent or returning `false` allows splash dismissal per platform timing; when present, the splash stays until the predicate is `false`. +- Animated drawable and `windowSplashScreenAnimationDuration` apply on API 31+; cap duration at **1000** ms on API 31+. +- Set `postSplashScreenTheme` to the theme used after splash handoff. + +### Startup Optimization Checklist + +- [ ] Audit `ContentProvider` usage - remove or replace with App Startup initializers +- [ ] Classify initializers as eager, after-first-frame, or on-demand +- [ ] Use `installSplashScreen()` with `setKeepOnScreenCondition` for loading state +- [ ] Test launcher splash on **minSdk**, **API 31+**, and at least one edge-to-edge or gesture-nav configuration ([Splash Screen](#splash-screen)) +- [ ] Generate Baseline Profiles for startup paths (see [Benchmark](#benchmark) section) +- [ ] Measure cold start time with Macrobenchmark before/after changes +- [ ] Avoid blocking the main thread with I/O, network, or heavy computation during startup +- [ ] Use `ProcessLifecycleOwner` or `Lifecycle` callbacks to defer non-critical work + +## Compose Recomposition Performance + +### Three Phases of Compose + +Every frame runs three phases. State reads in each phase only trigger work for that phase and later ones. + +| Phase | What Runs | State Read Triggers | +|-------|----------|-------------------| +| Composition | Composable functions, evaluates state | Recomposition of the reading scope | +| Layout | `measure` and `layout` blocks | Relayout only (no recomposition) | +| Drawing | `Canvas`, `DrawScope`, `drawBehind` | Redraw only (no recomposition or relayout) | + +**Rule:** Push state reads to the latest possible phase to minimize work. + +### Deferred State Reads + +Read state in the layout or draw phase instead of composition to avoid recomposition: + +```kotlin +// Bad: read in composition phase +@Composable +fun AnimatedBox(offsetState: State) { + val x = offsetState.value + Box(modifier = Modifier.offset(x.dp, 0.dp)) +} + +// Good: deferred to layout phase +@Composable +fun AnimatedBox(offsetState: State) { + Box( + modifier = Modifier.offset { + IntOffset(offsetState.value.roundToInt(), 0) + } + ) +} + +// Best: deferred to draw phase +@Composable +fun AnimatedBox(offsetState: State) { + Box( + modifier = Modifier.graphicsLayer { + translationX = offsetState.value + } + ) +} +``` + +Key lambda-based modifiers that defer reads: +- `Modifier.offset { }` - defers to layout phase +- `Modifier.graphicsLayer { }` - defers to draw phase +- `Modifier.drawBehind { }` - defers to draw phase + +### Strong Skipping Mode + +Enabled by default on the current Compose compiler. Recomposition skipping rules: + +- Composables skip recomposition if all parameters are unchanged +- Lambdas are stable if all captured variables are stable +- `@Stable` and `@Immutable` annotations are critical for custom types + +```kotlin +// Good: stable lambda (captures only stable Int) +@Composable +fun Counter(count: Int) { + Button(onClick = { println(count) }) { + Text("Count: $count") + } +} + +// Bad: unstable parameter +@Composable +fun UserCard(config: Config) { + Text(config.title) +} + +// Fix +@Immutable +data class Config(val title: String, val color: Color) +``` + +Stability annotations (`@Immutable`, `@Stable`): [compose-patterns.md → Stability Annotations](/references/compose-patterns.md#stability-annotations-immutable-vs-stable). + +### derivedStateOf - Reducing Recomposition Frequency + +Only recomposes when the derived result actually changes, not on every input change: + +```kotlin +// Bad: filter recomputed every recomposition +@Composable +fun FilteredList(items: List, query: String) { + val filtered = items.filter { query in it.title } + LazyColumn { + items(filtered) { ItemRow(it) } + } +} + +// Good +@Composable +fun FilteredList(items: List, query: String) { + val filtered by remember(items, query) { + derivedStateOf { items.filter { query in it.title } } + } + LazyColumn { + items(filtered) { ItemRow(it) } + } +} +``` + +Also useful for scroll-dependent UI: + +```kotlin +val listState = rememberLazyListState() +val showScrollToTop by remember { + derivedStateOf { listState.firstVisibleItemIndex > 0 } +} +``` + +Only use `derivedStateOf` for non-trivial computations. For cheap operations (string concat, simple boolean), the overhead isn't worth it. + +### remember with Keys + +```kotlin +// Bad: recomputed every recomposition +val metadata = computeMetadata(id) + +// Good +val metadata = remember(id) { computeMetadata(id) } + +// Multiple keys +val data = remember(id, userId) { fetchData(id, userId) } +``` + +Forbidden: wrapping cheap values (literals, primitives, single-property data classes) in `remember`. + +### R8/ProGuard Compose Rules + +Preserve stability annotations in release builds: + +```proguard +# Keep Compose stability annotations for recomposition skipping +-keep @androidx.compose.runtime.Stable class ** +-keep @androidx.compose.runtime.Immutable class ** +-keepclassmembers class * { + @androidx.compose.runtime.Stable ; +} +``` + +Ensure `minifyEnabled` and `shrinkResources` are enabled: + +```kotlin +// app/build.gradle.kts +android { + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } +} +``` + +Full R8 rules: `assets/proguard-rules.pro.template`. + +### Layout Inspector - Recomposition Counts + +Measure recompositions in Android Studio: + +1. Run app on device +2. **Tools > Layout Inspector** > select process +3. Enable **Show Composition Counts** toggle +4. Interact with the app - counts show how many times each composable recomposed + +High recomposition counts indicate: +- Unstable parameters (add `@Immutable`/`@Stable`) +- State reads in wrong scope (defer to layout/draw phase) +- Missing `remember` on expensive computations +- Lambda allocations (wrap in `remember` or use method references) + +### Common Hot Paths + +```kotlin +// Bad: new ButtonColors per recomposition +Button( + colors = ButtonDefaults.buttonColors( + containerColor = if (isPressed) Color.Red else Color.Blue + ) +) { Text("Click") } +// Good +val buttonColors = remember(isPressed) { + ButtonDefaults.buttonColors( + containerColor = if (isPressed) Color.Red else Color.Blue + ) +} +Button(colors = buttonColors) { Text("Click") } + +// Bad: filter inside items() +LazyColumn { + items(items.filter(predicate)) { ItemRow(it) } +} +// Good +val filtered by remember(items, predicate) { + derivedStateOf { items.filter(predicate) } +} +LazyColumn { + items(filtered) { ItemRow(it) } +} + +// Bad: per-item remember + missing key +LazyColumn { + items(users) { user -> + val state = remember { mutableStateOf(user) } + UserRow(state.value) + } +} +// Good +LazyColumn { + items(users, key = { it.id }) { user -> + UserRow(user) + } +} +``` + +### Text Input Performance + +`BasicTextField2` (`rememberTextFieldState()`) is required for high-frequency input; `TextField` / `OutlinedTextField` round-trip through the ViewModel and drop keystrokes under load. + +```kotlin +// Bad +var text by remember { mutableStateOf("") } +TextField(value = text, onValueChange = { text = it }) + +// Good +val state = rememberTextFieldState() +BasicTextField2(state = state) +``` + +### Performance Checklist + +- [ ] Use `BasicTextField2` for all text inputs to prevent dropped keystrokes. +- [ ] Use `derivedStateOf` when reading scroll state or filtering lists. +- [ ] Defer state reads to the layout or draw phase when animating (e.g., `Modifier.offset { }`, `Modifier.graphicsLayer { }`). +- [ ] Ensure all domain models passed to Compose are `@Immutable` or `@Stable`. +- [ ] Use `key` and `contentType` in all `LazyColumn`/`LazyRow` items. +- [ ] Avoid calling `refresh()` on PagingData inside a composable body. + +## References +- Splash screen: https://developer.android.com/develop/ui/views/launch/splash-screen +- Migrate to the Splash Screen API: https://developer.android.com/develop/ui/views/launch/splash-screen/migrate +- Benchmarking overview: https://developer.android.com/topic/performance/benchmarking/benchmarking-overview +- Macrobenchmark overview: https://developer.android.com/topic/performance/benchmarking/macrobenchmark-overview +- Macrobenchmark metrics: https://developer.android.com/topic/performance/benchmarking/macrobenchmark-metrics +- Macrobenchmark control app: https://developer.android.com/topic/performance/benchmarking/macrobenchmark-control-app +- Baseline Profiles overview: https://developer.android.com/topic/performance/baselineprofiles/overview +- Create Baseline Profiles: https://developer.android.com/topic/performance/baselineprofiles/create-baselineprofile +- Configure Baseline Profiles: https://developer.android.com/topic/performance/baselineprofiles/configure-baselineprofiles +- Measure Baseline Profiles: https://developer.android.com/topic/performance/baselineprofiles/measure-baselineprofile +- Android `perfetto` CLI and tools: https://developer.android.com/tools/perfetto +- Perfetto tracing docs (overview): https://perfetto.dev/docs/ +- Perfetto: How do I start using Perfetto?: https://perfetto.dev/docs/getting-started/start-using-perfetto diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-permissions.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-permissions.md new file mode 100644 index 000000000..b76a0c04a --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-permissions.md @@ -0,0 +1,741 @@ +# Android Runtime Permissions + +Compose-first runtime permission patterns. Declare in the `:app` manifest only; request contextually from Screen composables. All code must align with `references/kotlin-patterns.md` and `references/compose-patterns.md`. + +## Table of Contents +1. [Where Permissions Live](#where-permissions-live) +2. [Common Permission Sets](#common-permission-sets) +3. [Requesting Runtime Permissions in Compose](#requesting-runtime-permissions-in-compose) +4. [Requesting Special Permissions](#requesting-special-permissions) +5. [Rationale and Don't Ask Again](#rationale-and-dont-ask-again) +6. [Version-Specific Handling](#version-specific-handling) +7. [Android 16 (API 36) Permission Changes](#android-16-api-36-permission-changes) +8. [Testing](#testing) + +## Where Permissions Live + +- Declare permissions in the **app** module `AndroidManifest.xml`. +- Feature modules should expose capabilities (e.g., "requires camera") and the app decides whether to include and request them. + +```xml + + + + + + +``` + +## Common Permission Sets + +### Network (Normal) +Auto-granted when declared. No runtime request needed. + +```xml + + +``` + +### Camera (Runtime) + +```xml + +``` + +### Media Access (Runtime, Android 13+) +**Required:** use the Photo Picker when UX allows picking without `READ_MEDIA_*`; it avoids those runtime permissions on supported APIs. + +```xml + + + + + + + + + +``` + +### Notifications (Runtime, Android 13+) + +```xml + +``` + +Notification implementation, channels, and foreground services: `references/android-notifications.md`. + +### Location (Runtime) + +```xml + + +``` + +## Requesting Runtime Permissions in Compose + +Use `rememberLauncherForActivityResult` with `ActivityResultContracts.RequestPermission` or `RequestMultiplePermissions`. + +Accompanist permission helpers are deprecated. Use the native Compose APIs below. + +### Single Permission (Camera) + +Place permission logic in Screen composables, never in ViewModels. + +```kotlin +@Composable +fun CameraScreen( + onPhotoCaptured: (Bitmap) -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + var showRationale by remember { mutableStateOf(false) } + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { isGranted -> + if (isGranted) { + // Open camera + } else { + showRationale = true + } + } + + Column(modifier = modifier.fillMaxSize()) { + if (showRationale) { + PermissionRationaleCard( + title = "Camera Access Required", + description = "We need camera access to take photos.", + onDismiss = { showRationale = false }, + onOpenSettings = { openAppSettings(context) } + ) + } + + Button( + onClick = { + when (PackageManager.PERMISSION_GRANTED) { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.CAMERA + ) -> { + // Open camera + } + else -> launcher.launch(Manifest.permission.CAMERA) + } + } + ) { + Text("Take Photo") + } + } +} +``` + +### Multiple Permissions (Media Access) + +```kotlin +@Composable +fun MediaPickerScreen( + onMediaSelected: (Uri) -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + var showRationale by remember { mutableStateOf(false) } + + val permissions = buildMediaPermissions() + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions() + ) { permissionsMap -> + when { + permissionsMap.values.any { it } -> { + // At least one permission granted + } + else -> showRationale = true + } + } + + Button( + onClick = { + val hasPermission = permissions.any { permission -> + ContextCompat.checkSelfPermission( + context, + permission + ) == PackageManager.PERMISSION_GRANTED + } + + if (hasPermission) { + // Open media picker + } else { + launcher.launch(permissions.toTypedArray()) + } + } + ) { + Text("Choose Media") + } +} +``` + +### Notifications Permission (Android 13+) + +Request notifications contextually after user performs an action that benefits from notifications. + +```kotlin +@Composable +fun NotificationSettingsScreen( + viewModel: NotificationViewModel = hiltViewModel(), + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { isGranted -> + viewModel.onNotificationPermissionResult(isGranted) + } + + Column(modifier = modifier.fillMaxSize()) { + SwitchRow( + title = "Enable Notifications", + description = "Get notified about important updates", + checked = uiState.notificationsEnabled, + onCheckedChange = { enabled -> + if (enabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + when (PackageManager.PERMISSION_GRANTED) { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) -> viewModel.enableNotifications() + else -> launcher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } else { + viewModel.toggleNotifications(enabled) + } + } + ) + } +} +``` + +### Photo Picker (Preferred for Media on Android 13+) + +Start here for **permission-free** picks. For a single router that also lists document contracts, FileProvider, URI grants, and sharesheet targets, see [android-media.md → Picking media and documents](android-media.md#picking-media-and-documents). + +Photo Picker avoids permission requests entirely. Use this instead of requesting media permissions when possible. + +Photo Picker requires API 33+. On API 24-32, fall back to the legacy media permission flow (`READ_EXTERNAL_STORAGE`). + +```kotlin +@Composable +fun PhotoPickerScreen( + onPhotoSelected: (Uri) -> Unit, + modifier: Modifier = Modifier +) { + // Photo Picker requires API 33+ (Android 13+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia() + ) { uri -> + uri?.let { onPhotoSelected(it) } + } + + Button( + onClick = { + launcher.launch( + PickVisualMediaRequest( + mediaType = ActivityResultContracts.PickVisualMedia.ImageOnly + ) + ) + } + ) { + Text("Choose Photo") + } + } else { + // Fallback for API < 33: Use legacy image picker with READ_EXTERNAL_STORAGE permission + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri -> + uri?.let { onPhotoSelected(it) } + } + + Button( + onClick = { launcher.launch("image/*") } + ) { + Text("Choose Photo") + } + } +} + +// For multiple photos +@Composable +fun MultiPhotoPickerScreen( + onPhotosSelected: (List) -> Unit, + maxItems: Int = 10, + modifier: Modifier = Modifier +) { + // Photo Picker requires API 33+ (Android 13+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems) + ) { uris -> + if (uris.isNotEmpty()) { + onPhotosSelected(uris) + } + } + + Button( + onClick = { + launcher.launch( + PickVisualMediaRequest( + mediaType = ActivityResultContracts.PickVisualMedia.ImageOnly + ) + ) + } + ) { + Text("Choose Photos") + } + } else { + // Fallback for API < 33: Use legacy multiple files picker + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenMultipleDocuments() + ) { uris -> + if (uris.isNotEmpty()) { + onPhotosSelected(uris) + } + } + + Button( + onClick = { launcher.launch(arrayOf("image/*")) } + ) { + Text("Choose Photos") + } + } +} +``` + +## Requesting Special Permissions + +Special permissions (like exact alarms, all files access) require users to grant them from system settings. Apps cannot show a permission dialog; instead, they redirect users to the settings page. + +### Exact Alarms (Special Permission) + +```kotlin +@Composable +fun ScheduleEmailScreen( + viewModel: EmailViewModel = hiltViewModel(), + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val alarmManager = remember { context.getSystemService()!! } + var showRationale by remember { mutableStateOf(false) } + + val settingsLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { + // Check permission on return + } + + LaunchedEffect(Unit) { + // Check permission on resume + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (!alarmManager.canScheduleExactAlarms()) { + showRationale = true + } + } + } + + if (showRationale) { + AlertDialog( + onDismissRequest = { showRationale = false }, + title = { Text("Exact Alarm Permission Required") }, + text = { + Text("To send your email at the exact time you choose, we need permission to schedule exact alarms. Tap 'Grant' to open settings.") + }, + confirmButton = { + TextButton( + onClick = { + showRationale = false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + settingsLauncher.launch( + Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM) + ) + } + } + ) { + Text("Grant") + } + }, + dismissButton = { + TextButton(onClick = { showRationale = false }) { + Text("Cancel") + } + } + ) + } + + Button( + onClick = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (alarmManager.canScheduleExactAlarms()) { + viewModel.scheduleEmail() + } else { + showRationale = true + } + } else { + viewModel.scheduleEmail() + } + } + ) { + Text("Schedule Email") + } +} +``` + +### All Files Access (Special Permission) + +```kotlin +@Composable +fun FileManagerScreen( + modifier: Modifier = Modifier +) { + val context = LocalContext.current + var showRationale by remember { mutableStateOf(false) } + + val settingsLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { + // Check permission on return + } + + if (showRationale) { + AlertDialog( + onDismissRequest = { showRationale = false }, + title = { Text("All Files Access Required") }, + text = { + Text("To manage all your files, we need access to all storage. Tap 'Grant' to open settings and enable 'All files access'.") + }, + confirmButton = { + TextButton( + onClick = { + showRationale = false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + settingsLauncher.launch( + Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply { + data = Uri.fromParts("package", context.packageName, null) + } + ) + } + } + ) { + Text("Grant") + } + }, + dismissButton = { + TextButton(onClick = { showRationale = false }) { + Text("Cancel") + } + } + ) + } +} +``` + +## Rationale and Don't Ask Again + +### Rules + +Required: +- Request only inside the user action that needs the capability (e.g., the "Take Photo" tap). Never on app startup or screen entry. +- Show a rationale dialog before the system prompt when `shouldShowRequestPermissionRationale()` returns `true`. +- After denial-then-rationale-then-denial, route to system Settings via `Settings.ACTION_APPLICATION_DETAILS_SETTINGS`. +- Track denial count in `SavedStateHandle` (or a repository). `shouldShowRequestPermissionRationale` alone is unreliable across process death. + +Forbidden: +- Requesting batches of unrelated permissions in a single launcher call. +- Re-prompting in a loop after the user denies - wait for the next contextual action. + +### Open App Settings + +```kotlin +fun openAppSettings(context: Context) { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + context.startActivity(intent) +} +``` + +### Rationale Dialog Component + +```kotlin +@Composable +fun PermissionRationaleCard( + title: String, + description: String, + onDismiss: () -> Unit, + onOpenSettings: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium + ) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + TextButton(onClick = onDismiss) { + Text("Not Now") + } + Button(onClick = onOpenSettings) { + Text("Open Settings") + } + } + } + } +} +``` + +### Track Denial Count (Proper Pattern) + +```kotlin +@HiltViewModel +class CameraViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle +) : ViewModel() { + private var denialCount: Int + get() = savedStateHandle["camera_denial_count"] ?: 0 + set(value) { savedStateHandle["camera_denial_count"] = value } + + fun onPermissionDenied() { + denialCount++ + } + + fun shouldShowSettings(): Boolean = denialCount >= 2 +} +``` + +## Version-Specific Handling + +### Media Permissions (Android 14+ Partial Access) + +Android 14 introduced partial media access where users can grant access to selected photos only. + +```kotlin +fun buildMediaPermissions(): List = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> listOf( + Manifest.permission.READ_MEDIA_IMAGES, + Manifest.permission.READ_MEDIA_VIDEO, + Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED + ) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> listOf( + Manifest.permission.READ_MEDIA_IMAGES, + Manifest.permission.READ_MEDIA_VIDEO + ) + else -> listOf( + Manifest.permission.READ_EXTERNAL_STORAGE + ) +} + +fun checkMediaPermission(context: Context): MediaAccessLevel = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> { + when { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_MEDIA_IMAGES + ) == PackageManager.PERMISSION_GRANTED -> MediaAccessLevel.Full + + ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED + ) == PackageManager.PERMISSION_GRANTED -> MediaAccessLevel.Partial + + else -> MediaAccessLevel.None + } + } + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> { + if (ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_MEDIA_IMAGES + ) == PackageManager.PERMISSION_GRANTED + ) { + MediaAccessLevel.Full + } else { + MediaAccessLevel.None + } + } + else -> { + if (ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_EXTERNAL_STORAGE + ) == PackageManager.PERMISSION_GRANTED + ) { + MediaAccessLevel.Full + } else { + MediaAccessLevel.None + } + } +} + +enum class MediaAccessLevel { + Full, Partial, None +} +``` + +### Notification Permissions (Android 13+) + +```kotlin +fun shouldRequestNotificationPermission(context: Context): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED +} +``` + +## Android 16 (API 36) Permission Changes + +### Health & Fitness Permissions + +Apps targeting API 36 must migrate from `BODY_SENSORS` / `BODY_SENSORS_BACKGROUND` to granular `android.permissions.health` permissions. This affects heart rate, SpO2, and skin temperature sensors. + +```xml + + + + + + + + + +``` + +**Required:** Apps declaring granular `android.permission.health.*` reads must register an activity that renders the privacy policy (Health Connect parity). Missing that activity yields revocation of health permissions. + +```kotlin +fun buildHealthPermissions(): List = when { + Build.VERSION.SDK_INT >= 36 -> listOf( + "android.permission.health.READ_HEART_RATE", + "android.permission.health.READ_OXYGEN_SATURATION" + ) + else -> listOf( + Manifest.permission.BODY_SENSORS + ) +} +``` + +### Local Network Permission + +Android 16 introduces local network access protection. Apps that access devices on the local network (mDNS, SSDP, NsdManager, raw sockets to LAN addresses) will need user permission. + +**Current state (API 36 opt-in phase):** +- Feature is opt-in for testing; enforcement begins in a future release +- Declare `NEARBY_WIFI_DEVICES` permission for local network access +- All networking APIs are affected (sockets, OkHttp, Cronet, etc.) + +```xml + +``` + +**What is affected:** +- Outgoing/incoming TCP connections to LAN addresses +- UDP unicast, multicast, and broadcast to/from LAN +- mDNS and SSDP service discovery +- Any traffic to RFC1918 addresses (10.x, 172.16.x, 192.168.x), link-local, and multicast + +**Exceptions:** +- DNS traffic to a local DNS server (port 53) +- Normal internet traffic is unaffected + +```kotlin +@Composable +fun LocalNetworkPermissionRequest( + onPermissionResult: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { isGranted -> + onPermissionResult(isGranted) + } + + Button( + onClick = { + launcher.launch(Manifest.permission.NEARBY_WIFI_DEVICES) + }, + modifier = modifier + ) { + Text("Grant Network Access") + } +} +``` + +### App-Owned Photos Pre-Selection + +When targeting API 36, the photo picker pre-selects photos owned by the requesting app. Users can deselect these to revoke access. No code changes are needed, but be aware that users may deselect previously accessible photos. + +## Testing + +### Grant Permission in Tests + +```kotlin +@get:Rule +val permissionRule = GrantPermissionRule.grant( + Manifest.permission.CAMERA, + Manifest.permission.POST_NOTIFICATIONS +) + +@Test +fun testCameraFeature() { + // Permission automatically granted + composeTestRule.setContent { + CameraScreen(onPhotoCaptured = {}) + } + + composeTestRule.onNodeWithText("Take Photo").performClick() +} +``` + +### Test Permission Denial Flow + +```kotlin +@Test +fun testPermissionDenialShowsRationale() { + composeTestRule.setContent { + CameraScreen(onPhotoCaptured = {}) + } + + composeTestRule.onNodeWithText("Take Photo").performClick() + + // Simulate denial + composeTestRule.onNodeWithText("Camera Access Required").assertIsDisplayed() +} +``` + +### Performance Checks (Macrobenchmark) +If permission flows impact startup or navigation timing, use Macrobenchmark to measure. See `references/android-performance.md` for setup. + +## References +- Request runtime permissions: https://developer.android.com/training/permissions/requesting +- Request special permissions: https://developer.android.com/training/permissions/requesting-special +- Photo Picker: https://developer.android.com/training/data-storage/shared/photopicker +- App permissions best practices: https://developer.android.com/training/permissions/best-practices diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-security.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-security.md new file mode 100644 index 000000000..32cbf16d0 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-security.md @@ -0,0 +1,1779 @@ +# Android Security + +Required: server is the trust boundary; the client only collects credentials and forwards integrity tokens. Layer Play Integrity (server-decoded) + Android Keystore + EncryptedSharedPreferences/EncryptedFile + network security config. Heuristic root/emulator checks are telemetry only - never the sole gate. + +## Table of Contents +1. [Device trust and abuse resistance](#device-trust-and-abuse-resistance) +2. [Network Security](#network-security) +3. [Certificate Pinning](#certificate-pinning) +4. [Data Encryption at Rest](#data-encryption-at-rest) +5. [Android Keystore, TEE & StrongBox](#android-keystore-tee--strongbox) +6. [Biometric Authentication](#biometric-authentication) +7. [Credential Manager and Sign-In](#credential-manager-and-sign-in) +8. [Device Identifiers and Privacy](#device-identifiers-and-privacy) +9. [Android 15+ Platform Privacy](#android-15-platform-privacy) +10. [Play Console Data Safety](#play-console-data-safety) +11. [Play Integrity API](#play-integrity-api) +12. [Root & Emulator Detection](#root--emulator-detection) +13. [Screenshot & Screen Recording Prevention](#screenshot--screen-recording-prevention) +14. [Secure Database (Room)](#secure-database-room) +15. [Secure Clipboard](#secure-clipboard) +16. [WebView Security](#webview-security) +17. [Content Provider Security](#content-provider-security) +18. [ProGuard / R8 Hardening](#proguard--r8-hardening) +19. [CI/CD Security](#cicd-security) +20. [Security Checklist](#security-checklist) + +## Dependencies + +Security-related libraries available in the version catalog: + +- `androidx-biometric` - BiometricPrompt (fingerprint, face) +- `androidx-security-crypto` - EncryptedSharedPreferences, EncryptedFile +- `play-integrity` - Play Integrity API (device/app attestation) +- `sqlcipher-android` - SQLCipher for encrypted Room databases + +Add them to your module as needed, following [dependencies.md → Adding a New Dependency](/references/dependencies.md#adding-a-new-dependency). + +## Device trust and abuse resistance + +Apply for high-value flows: login, payment, account change. Establish that *this app binary* on *this device* is trustworthy *for this specific request* - not a single client-side boolean. + +### Client-only heuristics are insufficient + +Local `su` / Magisk / package checks are evadable and tamperable. Use them only as telemetry. Never treat "root detected" / "not detected" as the sole authorization signal. + +### Trust inputs (server-verifiable) + +- App binary matches what Play expects (**app integrity**). +- Install/account context is legitimate (**licensing / account signals**). +- Device environment meets policy (**device integrity** and optional signals). +- Integrity token binds to this exact server request (`requestHash` for Standard API, `nonce` for Classic - see [Play Integrity API](#play-integrity-api)). + +[Play Integrity API](https://developer.android.com/google/play/integrity/overview) emits these as server-verifiable signals. + +### Implementation order + +1. Backend is authoritative. Decrypt/verify tokens server-side; apply **tiered** policy (allow / step-up / rate-limit / deny the specific operation). Never let the client be the only enforcer. +2. Use Play Integrity for Play-distributed apps. Integrate **Standard** for frequent checks (prepare provider, request with `requestHash`); use **Classic** for rare high-value checks (`nonce`). See [Play Integrity API](#play-integrity-api). +3. Bind every token to the action: hash a canonical request representation. Never put secrets in plaintext into the hash input. +4. Roll out enforcement gradually: log verdicts first, then tighten rules. +5. Combine with Android Keystore-backed keys for device-bound signing/encryption of high-value operations (see [Android Keystore, TEE & StrongBox](#android-keystore-tee--strongbox)). +6. Treat optional runtime signals (overlays, accessibility abuse, automation) as risk inputs to policy/fraud engines - not the sole gate unless product requires it. + +Reference: [Play Integrity API overview](https://developer.android.com/google/play/integrity/overview). + +## Network Security + +### Network Security Configuration + +At target SDK 37, `android:usesCleartextTraffic` defaults to `false` and every HTTP request fails without a Network Security Config domain allowlist. Production: leave the manifest attribute absent or `false` and rely on the implicit deny. Dev/test: scope cleartext to a single `` for the local backend, never to the whole app. + +Create `res/xml/network_security_config.xml`: + +```xml + + + + + + + + + + + + + + + + +``` + +Reference in `AndroidManifest.xml`: + +```xml + + +``` + +### Certificate Transparency (API 37) + +At target SDK 37, Certificate Transparency is enforced by default for every HTTPS connection routed through the platform stack. Per-domain opt-out is reserved for endpoints whose certs are issued by a non-public CA (internal corporate CAs, captive portals); never disable globally. + +```xml + + + internal.example.com + + +``` + +Forbidden: a global `` (disables CT for every domain and undoes the platform default). + +### Loopback access (API 37) + +Apps targeting Android 17 must declare `android.permission.USE_LOOPBACK_INTERFACE` to reach `127.0.0.1` / `::1` from another app's process. Same-process loopback (instrumented tests against `MockWebServer` in the test process) is unaffected. + +```xml + +``` + +Required only for: cross-process bridges to a local Web/Node/HTTP server (custom dev shells, Detox-style test harnesses). Production apps that only talk to remote HTTPS endpoints do not need this permission. + +### OkHttp Security Configuration + +```kotlin +// core/network/di/NetworkModule.kt +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + @Provides + @Singleton + fun provideOkHttpClient(): OkHttpClient { + return OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + // TLS 1.2+ only (default on API 24+, but explicit is better) + .connectionSpecs(listOf(ConnectionSpec.MODERN_TLS)) + // Redirect policy + .followRedirects(true) + .followSslRedirects(true) + .build() + } +} +``` + +### Preventing Man-in-the-Middle Attacks + +- Enforce HTTPS for all API endpoints (via network security config) +- Use certificate pinning for critical endpoints (see below) +- Validate server certificates +- Disable cleartext traffic in production + +## Certificate Pinning + +Pin your server's public key hash to prevent MITM attacks even with compromised CAs. + +### Option 1: Network Security Config (Recommended) + +```xml + + + + + + + + + + + api.example.com + + + base64EncodedSHA256PinHere= + + base64EncodedBackupPinHere= + + + + + + + + + +``` + +### Option 2: OkHttp Certificate Pinner (Programmatic) + +For more control (e.g., dynamic pins, per-request): + +```kotlin +// core/network/di/NetworkModule.kt +@Provides +@Singleton +fun provideOkHttpClient(): OkHttpClient { + val certificatePinner = CertificatePinner.Builder() + .add( + "api.example.com", + "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" // Primary + ) + .add( + "api.example.com", + "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=" // Backup + ) + .build() + + return OkHttpClient.Builder() + .certificatePinner(certificatePinner) + .connectionSpecs(listOf(ConnectionSpec.MODERN_TLS)) + .build() +} +``` + +### Extracting Pin Hashes + +```bash +# From a live server +openssl s_client -servername api.example.com -connect api.example.com:443 \ + 2>/dev/null | openssl x509 -pubkey -noout | \ + openssl pkey -pubin -outform der | \ + openssl dgst -sha256 -binary | openssl enc -base64 + +# From a certificate file +openssl x509 -in server.crt -pubkey -noout | \ + openssl pkey -pubin -outform der | \ + openssl dgst -sha256 -binary | openssl enc -base64 +``` + +### Pin rotation and monitoring + +- **Always include a backup pin** (intermediate or root CA) to avoid lockout during cert rotation +- **Set expiration dates** on pin-sets so expired pins don't brick the app +- **Use network security config** (Option 1) for static pins, OkHttp for dynamic pins +- **Monitor pin failures** in production: log pin mismatch events to crash reporter +- **Test before release**: Verify pins work in staging environment + +## Data Encryption at Rest + +### EncryptedSharedPreferences + +For storing small secrets (tokens, keys, flags): + +```kotlin +// core/data/storage/SecurePreferences.kt +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey + +class SecurePreferences @Inject constructor( + @ApplicationContext private val context: Context +) { + private val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .setRequestStrongBoxBacked(true) // Use StrongBox if available + .build() + + private val prefs = EncryptedSharedPreferences.create( + context, + "secure_prefs", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + fun saveAuthToken(token: String) { + prefs.edit().putString(KEY_AUTH_TOKEN, token).apply() + } + + fun getAuthToken(): String? = prefs.getString(KEY_AUTH_TOKEN, null) + + fun saveRefreshToken(token: String) { + prefs.edit().putString(KEY_REFRESH_TOKEN, token).apply() + } + + fun getRefreshToken(): String? = prefs.getString(KEY_REFRESH_TOKEN, null) + + fun clearAll() { + prefs.edit().clear().apply() + } + + companion object { + private const val KEY_AUTH_TOKEN = "auth_token" + private const val KEY_REFRESH_TOKEN = "refresh_token" + } +} +``` + +### EncryptedFile + +For larger encrypted data (documents, cached files): + +```kotlin +import androidx.security.crypto.EncryptedFile +import androidx.security.crypto.MasterKey + +class SecureFileStorage @Inject constructor( + @ApplicationContext private val context: Context +) { + private val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + fun writeSecureFile(filename: String, data: ByteArray) { + val file = File(context.filesDir, filename) + if (file.exists()) file.delete() + + val encryptedFile = EncryptedFile.Builder( + context, + file, + masterKey, + EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB + ).build() + + encryptedFile.openFileOutput().use { output -> + output.write(data) + } + } + + fun readSecureFile(filename: String): ByteArray? { + val file = File(context.filesDir, filename) + if (!file.exists()) return null + + val encryptedFile = EncryptedFile.Builder( + context, + file, + masterKey, + EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB + ).build() + + return encryptedFile.openFileInput().use { input -> + input.readBytes() + } + } +} +``` + +### Bank-Level Encryption (AES-256-GCM) + +For custom encryption when you need full control (e.g., encrypting data before sending to server): + +```kotlin +// core/data/crypto/AesGcmEncryption.kt +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +class AesGcmEncryption { + + companion object { + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val GCM_TAG_LENGTH = 128 + private const val GCM_IV_LENGTH = 12 + } + + fun getOrCreateKey(alias: String): SecretKey { + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE) + keyStore.load(null) + + keyStore.getEntry(alias, null)?.let { entry -> + return (entry as KeyStore.SecretKeyEntry).secretKey + } + + val keyGenerator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + ANDROID_KEYSTORE + ) + + val spec = KeyGenParameterSpec.Builder( + alias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .setIsStrongBoxBacked(isStrongBoxAvailable()) + .setUserAuthenticationRequired(false) + .build() + + keyGenerator.init(spec) + return keyGenerator.generateKey() + } + + fun encrypt(data: ByteArray, key: SecretKey): ByteArray { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, key) + + val iv = cipher.iv + val encrypted = cipher.doFinal(data) + + // Prepend IV to ciphertext: [IV (12 bytes)][ciphertext + tag] + return iv + encrypted + } + + fun decrypt(encryptedData: ByteArray, key: SecretKey): ByteArray { + val iv = encryptedData.copyOfRange(0, GCM_IV_LENGTH) + val ciphertext = encryptedData.copyOfRange(GCM_IV_LENGTH, encryptedData.size) + + val cipher = Cipher.getInstance(TRANSFORMATION) + val spec = GCMParameterSpec(GCM_TAG_LENGTH, iv) + cipher.init(Cipher.DECRYPT_MODE, key, spec) + + return cipher.doFinal(ciphertext) + } + + private fun isStrongBoxAvailable(): Boolean { + return try { + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE) + keyStore.load(null) + android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P + } catch (_: Exception) { + false + } + } +} +``` + +### Software Fallback (No Hardware Security Module) + +When the device lacks TEE/StrongBox (rare but possible on very old devices): + +```kotlin +// core/data/crypto/SoftwareEncryption.kt +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec +import java.security.SecureRandom + +class SoftwareEncryption { + + fun generateKey(): ByteArray { + val keyGenerator = KeyGenerator.getInstance("AES") + keyGenerator.init(256, SecureRandom()) + return keyGenerator.generateKey().encoded + } + + fun encrypt(data: ByteArray, keyBytes: ByteArray): ByteArray { + val key = SecretKeySpec(keyBytes, "AES") + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key) + val iv = cipher.iv + val encrypted = cipher.doFinal(data) + return iv + encrypted + } + + fun decrypt(encryptedData: ByteArray, keyBytes: ByteArray): ByteArray { + val iv = encryptedData.copyOfRange(0, 12) + val ciphertext = encryptedData.copyOfRange(12, encryptedData.size) + val key = SecretKeySpec(keyBytes, "AES") + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv)) + return cipher.doFinal(ciphertext) + } +} +``` + +**Warning:** Store the software-generated key securely (e.g., derive from user password via PBKDF2). Never hardcode keys or store them in `SharedPreferences` in plaintext. + +## Android Keystore, TEE & StrongBox + +### What They Are + +- **Android Keystore**: System-level key storage backed by hardware (when available). Keys never leave the secure hardware. +- **TEE (Trusted Execution Environment)**: An isolated processing environment (e.g., ARM TrustZone) that runs alongside Android but is isolated from the main OS. Most modern Android devices have TEE support. +- **StrongBox**: A dedicated secure element (separate hardware chip). More secure than TEE because the key material is in a tamper-resistant chip, not solely an isolated CPU mode. Available since API 28 on devices that have a dedicated secure element. + +### How They Protect + +| Feature | TEE | StrongBox | +|-------------------------|----------------------|---------------------------| +| Hardware isolation | CPU trust zone | Dedicated chip | +| Side-channel resistance | Limited | High | +| Tamper resistance | Software-level | Physical tamper-resistant | +| Key extraction | Difficult | Near impossible | +| Availability | Most devices API 24+ | API 28+ (select devices) | + +### Using Hardware-Backed Keys + +```kotlin +// core/data/crypto/KeystoreManager.kt +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties + +class KeystoreManager @Inject constructor( + @ApplicationContext private val context: Context +) { + private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + + fun createKey( + alias: String, + requireBiometric: Boolean = false, + requireStrongBox: Boolean = false + ): SecretKey { + if (keyStore.containsAlias(alias)) { + return (keyStore.getEntry(alias, null) as KeyStore.SecretKeyEntry).secretKey + } + + val builder = KeyGenParameterSpec.Builder( + alias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + + if (requireBiometric) { + builder.setUserAuthenticationRequired(true) + builder.setUserAuthenticationParameters( + 0, // Every use requires auth + KeyProperties.AUTH_BIOMETRIC_STRONG + ) + builder.setInvalidatedByBiometricEnrollment(true) + } + + if (requireStrongBox && isStrongBoxAvailable()) { + builder.setIsStrongBoxBacked(true) + } + + val keyGenerator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + "AndroidKeyStore" + ) + keyGenerator.init(builder.build()) + return keyGenerator.generateKey() + } + + fun deleteKey(alias: String) { + if (keyStore.containsAlias(alias)) { + keyStore.deleteEntry(alias) + } + } + + fun isStrongBoxAvailable(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE) + } else { + false + } + } + + fun isHardwareBackedKeystore(): Boolean { + // TEE-backed on most devices API 24+ + return try { + val keyInfo = keyStore.getKey("test_key", null) + // Key generation test passed = hardware backed + true + } catch (_: Exception) { + false + } + } +} +``` + +### DI Integration + +```kotlin +// core/data/di/SecurityModule.kt +@Module +@InstallIn(SingletonComponent::class) +object SecurityModule { + + @Provides + @Singleton + fun provideSecurePreferences( + @ApplicationContext context: Context + ): SecurePreferences = SecurePreferences(context) + + @Provides + @Singleton + fun provideKeystoreManager( + @ApplicationContext context: Context + ): KeystoreManager = KeystoreManager(context) + + @Provides + @Singleton + fun provideAesGcmEncryption(): AesGcmEncryption = AesGcmEncryption() +} +``` + +## Biometric Authentication + +### BiometricPrompt Setup + +```kotlin +// core/ui/biometric/BiometricAuthenticator.kt +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity + +class BiometricAuthenticator { + + fun canAuthenticate(context: Context): BiometricStatus { + val biometricManager = BiometricManager.from(context) + + return when (biometricManager.canAuthenticate( + Authenticators.BIOMETRIC_STRONG or Authenticators.BIOMETRIC_WEAK + )) { + BiometricManager.BIOMETRIC_SUCCESS -> BiometricStatus.Available + BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> BiometricStatus.NoHardware + BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> BiometricStatus.HardwareUnavailable + BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> BiometricStatus.NoneEnrolled + BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> + BiometricStatus.SecurityUpdateRequired + else -> BiometricStatus.Unsupported + } + } + + fun authenticate( + activity: FragmentActivity, + title: String, + subtitle: String, + negativeButtonText: String, + onSuccess: (BiometricPrompt.AuthenticationResult) -> Unit, + onError: (Int, CharSequence) -> Unit, + onFailed: () -> Unit + ) { + val executor = ContextCompat.getMainExecutor(activity) + + val callback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + onSuccess(result) + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + onError(errorCode, errString) + } + + override fun onAuthenticationFailed() { + onFailed() + } + } + + val prompt = BiometricPrompt(activity, executor, callback) + + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setNegativeButtonText(negativeButtonText) + .setAllowedAuthenticators( + Authenticators.BIOMETRIC_STRONG or Authenticators.BIOMETRIC_WEAK + ) + .setConfirmationRequired(true) + .build() + + prompt.authenticate(promptInfo) + } + + fun authenticateWithCrypto( + activity: FragmentActivity, + cipher: Cipher, + title: String, + subtitle: String, + negativeButtonText: String, + onSuccess: (BiometricPrompt.AuthenticationResult) -> Unit, + onError: (Int, CharSequence) -> Unit + ) { + val executor = ContextCompat.getMainExecutor(activity) + + val callback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + onSuccess(result) + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + onError(errorCode, errString) + } + } + + val prompt = BiometricPrompt(activity, executor, callback) + + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setNegativeButtonText(negativeButtonText) + .setAllowedAuthenticators(Authenticators.BIOMETRIC_STRONG) + .build() + + prompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher)) + } +} + +enum class BiometricStatus { + Available, + NoHardware, + HardwareUnavailable, + NoneEnrolled, + SecurityUpdateRequired, + Unsupported +} +``` + +### Using Biometrics in Compose + +```kotlin +@Composable +fun BiometricLoginButton( + onAuthenticated: () -> Unit, + onError: (String) -> Unit +) { + val context = LocalContext.current + val activity = context as? FragmentActivity ?: return + val authenticator = remember { BiometricAuthenticator() } + + val canAuthenticate = remember { + authenticator.canAuthenticate(context) + } + + if (canAuthenticate != BiometricStatus.Available) return + + Button( + onClick = { + authenticator.authenticate( + activity = activity, + title = context.getString(R.string.biometric_title), + subtitle = context.getString(R.string.biometric_subtitle), + negativeButtonText = context.getString(R.string.biometric_cancel), + onSuccess = { onAuthenticated() }, + onError = { _, errString -> onError(errString.toString()) }, + onFailed = { onError("Authentication failed") } + ) + } + ) { + Text(stringResource(R.string.login_with_biometrics)) + } +} +``` + +### Biometric + Keystore (Bank-Level Security) + +For highest security, combine biometric auth with hardware-backed key: + +```kotlin +class BiometricCryptoManager @Inject constructor( + private val keystoreManager: KeystoreManager +) { + private val keyAlias = "biometric_key" + + fun createBiometricKey() { + keystoreManager.createKey( + alias = keyAlias, + requireBiometric = true, + requireStrongBox = true + ) + } + + fun getCipherForEncryption(): Cipher { + val key = keystoreManager.createKey( + alias = keyAlias, + requireBiometric = true + ) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key) + return cipher + } + + fun getCipherForDecryption(iv: ByteArray): Cipher { + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + val key = (keyStore.getEntry(keyAlias, null) as KeyStore.SecretKeyEntry).secretKey + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv)) + return cipher + } +} +``` + +## Credential Manager and Sign-In + +**BiometricPrompt** (above) covers local biometric unlock. For **sign-in**, Google recommends **Credential Manager** (`androidx.credentials`) as the unified API for **passkeys**, saved passwords, and federated identity (for example Sign in with Google) in one user flow. It replaces older Smart Lock Password Manager integration patterns for new work. + +- Use Credential Manager for new sign-in and account linking flows where it fits your backend (WebAuthn / passkeys require server support). +- Keep **server-side** validation authoritative; the client only collects credentials. +- See [Sign in your user with Credential Manager](https://developer.android.com/identity/sign-in/credential-manager) and [Passkeys](https://developer.android.com/identity/sign-in/passkeys). + +## Device Identifiers and Privacy + +Do **not** use hardware identifiers for advertising or routine analytics. Google Play policies restrict many identifiers; users expect resettable, transparent tracking. + +| Identifier | Guidance | +|-------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------| +| IMEI, IMSI, serial number, MAC address | Do not use for ads or general analytics; restricted / disallowed for most use cases | +| [Advertising ID](https://developer.android.com/training/articles/ad-id) | Use for ads and measurement where allowed; user can reset; declare in Play Data Safety | +| **Android ID** | App-scoped on modern Android; may change after factory reset; use only when appropriate, not as a global cross-app user ID | +| App-specific ID | Generate and store a random UUID in app storage or tie identity to your **account** after sign-in | + +Use **account-based** identity for personalization. For crash and product analytics without PII, follow `references/crashlytics.md` scrubbing rules. + +## Android 15+ Platform Privacy + +Android 15 (API 35) and 16 (API 36) add platform privacy features that change how apps access media, render protected UI, and coexist with user profiles. Handle each one explicitly; do not rely on older behavior. + +### Partial photo/media access (API 34+, enforced broadly on API 35+) + +When the app requests `READ_MEDIA_IMAGES` / `READ_MEDIA_VIDEO` on API 34+, the user can grant **selected items only** instead of full access. The platform returns `READ_MEDIA_VISUAL_USER_SELECTED` in that case. + +```xml + + + + +``` + +Rules: + +- **Use the Photo Picker** (`PickVisualMedia` / `ActivityResultContracts`) instead of broad media reads when UX allows. Details: `references/android-permissions.md`. The picker needs no media permission on supported APIs. +- If you *must* enumerate media (gallery-like apps), check the grant state and show a "Manage selected photos" entry point that re-invokes the picker via `ACTION_MANAGE_APP_PERMISSIONS` or a fresh `READ_MEDIA_VISUAL_USER_SELECTED` request. Do not silently fail when only partial access is granted. + +```kotlin +val fullAccess = ContextCompat.checkSelfPermission( + context, Manifest.permission.READ_MEDIA_IMAGES +) == PackageManager.PERMISSION_GRANTED + +val partialAccess = ContextCompat.checkSelfPermission( + context, Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED +) == PackageManager.PERMISSION_GRANTED + +when { + fullAccess -> loadAllMedia() + partialAccess -> loadSelectedMediaAndOfferReselection() + else -> showPhotoPickerPrompt() +} +``` + +### Screen recording detection (API 35+) + +Android 15 lets an app detect when its own UI is being captured by another app or service (MediaProjection, cast, third-party recorders). Use this to pause sensitive surfaces (bank balances, OTP screens, medical data) rather than replacing `FLAG_SECURE`. + +Manifest: + +```xml + +``` + +Registration (in an Activity hosting sensitive content): + +```kotlin +private val mainExecutor by lazy { ContextCompat.getMainExecutor(this) } + +private val screenRecordingCallback = Consumer { state -> + val visible = state == WindowManager.SCREEN_RECORDING_STATE_VISIBLE + sensitiveContentController.onRecordingStateChanged(visible) +} + +override fun onStart() { + super.onStart() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + windowManager.addScreenRecordingCallback(mainExecutor, screenRecordingCallback) + } +} + +override fun onStop() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + windowManager.removeScreenRecordingCallback(screenRecordingCallback) + } + super.onStop() +} +``` + +Rules: + +- The callback only detects *this app's* windows being recorded. It does not catch foreground-level global recording by system-signed tools. +- This is a **detection signal**, not a prevention mechanism. Pair it with `FLAG_SECURE` on screens that must never be captured (see [Screenshot & Screen Recording Prevention](#screenshot--screen-recording-prevention)). +- Do not use it as a DRM substitute. Determined attackers capture the framebuffer through other channels. + +### Private Space awareness (API 35+) + +Private Space is a user-level profile that stores a separate, locked copy of installed apps. It affects these surfaces: + +- **FileProvider / content sharing:** when sharing files via `Intent.ACTION_SEND`, do not assume the target resolver list is global. The private profile's apps are filtered out when the profile is locked. Let the system handle it; do not build custom resolver UIs. +- **Account linking:** the same Google account can be present in both the main and private profile. Do not dedupe users by on-device signals alone; server-side identity is authoritative (consistent with the rule in [Device Identifiers and Privacy](#device-identifiers-and-privacy)). +- **Querying installed apps:** `PackageManager.getInstalledApplications()` in one profile does not see apps installed in the other. Code paths that enumerate apps must not assume full visibility. + +No new API is required for most apps - the correctness fix is to stop making assumptions the old single-profile model allowed. + +### Partial screen sharing (API 34+) + +`MediaProjection` can now record a **single app window** rather than the whole display. If the app is the *source* of a screen-capture feature: + +- Expose the app-window option when calling `createScreenCaptureIntent()`; the user picks scope in the system dialog. +- Do not try to escalate from single-window to full-display capture; the system blocks it and the user experience degrades. + +If the app is the *target* being recorded, use the Screen recording detection callback above to react. + +### Official references + +- [Android 15 features and APIs](https://developer.android.com/about/versions/15/features) +- [Photo picker improvements and partial access](https://developer.android.com/training/data-storage/shared/photopicker) +- [Detect screen recording](https://developer.android.com/about/versions/15/features#screen-recording-detection) +- [Private Space](https://developer.android.com/about/versions/15/features#private-space) + +## Play Console Data Safety + +In Play Console, complete the **Data safety** section (what you collect, how it is used, whether it is optional, retention). It must match your **privacy policy** URL and in-app disclosures. + +- Allow **account and data deletion** where required by policy and your product. +- If you use Advertising ID or sensitive permissions, declare them accurately; mismatches can cause policy violations. + +See [Play Console Help - Data safety](https://support.google.com/googleplay/android-developer/answer/10787469) and [User Data policy](https://support.google.com/googleplay/android-developer/answer/10144311). + +## Play Integrity API + +Replaces SafetyNet Attestation API (deprecated). Verifies device integrity, app integrity, and licensing. Use **Standard** requests for most on-demand checks; reserve **Classic** for infrequent, high-value actions. Official docs: [Overview](https://developer.android.com/google/play/integrity/overview), [Setup](https://developer.android.com/google/play/integrity/setup), [Standard requests](https://developer.android.com/google/play/integrity/standard), [Classic requests](https://developer.android.com/google/play/integrity/classic). + +### Prerequisites and project setup + +**Steps 1-2 need a human with Google Cloud and Play Console access.** An AI cannot log into those consoles. When implementing Play Integrity in code, **ask the engineer** to complete enablement and linking first, then obtain the value(**numeric Cloud project number**) below so the client and backend can be wired correctly. + +1. **Google Cloud (engineer):** Create or select a project; enable the **Play Integrity API** ([Setup guide](https://developer.android.com/google/play/integrity/setup)). The engineer should share the **Google Cloud project number** (numeric, shown in Cloud Console for the project). You pass it to `PrepareIntegrityTokenRequest.setCloudProjectNumber` (Standard API) and to Classic requests when the docs require it. Backend teams create a **service account** in this project with access to call the Play Integrity **decode** API (see [Google's server verification docs](https://developer.android.com/google/play/integrity/standard#decrypt-and-verify-the-integrity-verdict)); those credentials stay on the server. +2. **Play Console (engineer):** Link that Cloud project to your app under **Test and release** > **App integrity** > **Play Integrity API** > **Link a Cloud project**. Linking is required for quota increases, response configuration in Console, and related tooling. Projects enabled only in Cloud Console but not linked get a limited integration path per Google. +3. **Quotas (defaults):** Roughly **10,000** integrity token operations and **10,000** server-side decryptions per day for the linked Cloud project (shared across request types; see [Setup](https://developer.android.com/google/play/integrity/setup) for current numbers and how to request more). +4. **Dependency:** add the Play Integrity library via the version catalog [`assets/libs.versions.toml.template`](../assets/libs.versions.toml.template) - `version.ref = "playIntegrity"`, library alias `play-integrity` (`com.google.android.play:integrity`). Mirror in `gradle/libs.versions.toml` and module `build.gradle.kts` (see [Dependencies](#dependencies)). + +### Standard API vs Classic API + +| | **Standard API** | **Classic API** | +|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------| +| **Warm-up** | Yes - call `prepareIntegrityToken` before you need tokens (typical warm-up a few seconds; allow a generous timeout, e.g. on the order of one minute) | No | +| **Typical latency after warm-up** | Lower (often hundreds of ms for the token request) | Higher (often a few seconds) | +| **Use when** | Frequent checks tied to user actions or API calls | Rare, high-value or sensitive actions | +| **Client binding field** | `requestHash` (digest of the protected request; max length per API) | `nonce` (server-chosen or derived; format per [Classic](https://developer.android.com/google/play/integrity/classic)) | +| **Replay / tamper mitigation** | Google Play mitigates replay for Standard; still bind with `requestHash` for request integrity | You must implement nonce handling and server checks | +| **Rate limits (documented)** | Prepare: **5** warm-up calls per app instance per minute; token requests subject to product limits | **5** integrity token requests per app instance per minute for Classic | + +Library `minSdk` for both follows the Play Integrity library version you ship (see release notes for the exact floor). + +### Standard API client flow + +- Create `StandardIntegrityManager` via `IntegrityManagerFactory.createStandard(context)`. +- **Once per session (or after errors below):** call `prepareIntegrityToken` with `PrepareIntegrityTokenRequest` that sets your **Google Cloud project number**. Keep the resulting `StandardIntegrityTokenProvider` in memory. +- **On each protected action:** build a stable digest of the data you need to bind (for example SHA-256 of a canonical string of request fields), pass it as **`requestHash`** in `StandardIntegrityTokenRequest`. Do not put sensitive values in plaintext in the hash input; hash them. +- If you receive **`INTEGRITY_TOKEN_PROVIDER_INVALID`**, prepare a new provider and retry the token request. +- Optional: use **`verdictOptOut`** on a Standard request to skip optional verdicts that add latency when you do not need them (see API reference / release notes). + +### Classic API client flow + +- Use `IntegrityManagerFactory.create(context)` and `IntegrityTokenRequest` with a **`nonce`** meeting Google's format (Base64 URL-safe, no wrap, length limits in the docs). +- Apps **distributed through Google Play** omit `setCloudProjectNumber` when Play Console already links the Play Integrity cloud project. +- Apps **not** installed from Play (or SDK integrations as documented) may need **`setCloudProjectNumber`** - follow [Classic requests](https://developer.android.com/google/play/integrity/classic). +- Use Classic **sparingly**; it is heavier and you own nonce and replay policy on the server. + +### Policy (enforcement) + +- **Do not** treat a decrypted verdict as a long-lived "device is trusted forever" flag in the client. Avoid caching integrity results to authorize unrelated later actions. +- Apply **tiered** server rules: allow, allow with limits, step-up (OTP, delay), or deny **only** the sensitive operation - avoid locking the whole app on the first failure unless product requires it. +- **Optional verdicts** (extra device labels, app access risk, Play Protect, recent device activity, device recall, etc.) require opting in under Play Console **App integrity** > **Play Integrity API** > **Settings** / **Change responses**. Only enforce signals you actually receive and have enabled. +- Roll out **telemetry first** (log or soft-fail), then tighten enforcement as you understand your user base. + +### Setup + +Add the Play Integrity dependency (see [Dependencies](#dependencies)). Call **`warmUp()`** once after launch (or in background) so the first protected action is not paying full prepare latency. Use **`requestIntegrityToken(requestHash)`** only with a digest built for that action (see [Standard API client flow](#standard-api-client-flow)). + +```kotlin +// core/data/integrity/PlayIntegrityChecker.kt +import com.google.android.play.core.integrity.IntegrityManagerFactory +import com.google.android.play.core.integrity.StandardIntegrityManager +import kotlinx.coroutines.tasks.await + +class PlayIntegrityChecker @Inject constructor( + @ApplicationContext private val context: Context +) { + private val integrityManager = IntegrityManagerFactory.createStandard(context) + + @Volatile + private var tokenProvider: StandardIntegrityManager.StandardIntegrityTokenProvider? = null + + /** Call once (e.g. Application onCreate or before first sensitive call). */ + suspend fun warmUp(): Result { + if (tokenProvider != null) return Result.success(Unit) + return try { + tokenProvider = integrityManager + .prepareIntegrityToken( + StandardIntegrityManager.PrepareIntegrityTokenRequest.builder() + .setCloudProjectNumber(YOUR_CLOUD_PROJECT_NUMBER) + .build() + ) + .await() + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** Request a token bound to this server action via requestHash (Standard API). */ + suspend fun requestIntegrityToken(requestHash: String): Result { + warmUp().getOrElse { return Result.failure(it) } + return try { + val request = StandardIntegrityManager.StandardIntegrityTokenRequest.builder() + .setRequestHash(requestHash) + .build() + val tokenResponse = tokenProvider!!.request(request).await() + Result.success(tokenResponse.token()) + } catch (e: Exception) { + tokenProvider = null + Result.failure(e) + } + } +} +``` + +### Server-Side Verification + +**The integrity token must be verified server-side.** Never trust client-side validation alone. The backend calls Google's **`decodeIntegrityToken`** API with a service account (see [Decrypt and verify the integrity verdict](https://developer.android.com/google/play/integrity/standard#decrypt-and-verify-the-integrity-verdict)). Recompute **`requestHash`** the same way as the client and compare to **`requestDetails.requestHash`** in the decrypted payload. + +```kotlin +// Send token + the same requestHash your server will recompute for verification +class IntegrityRepository @Inject constructor( + private val api: IntegrityApi, + private val integrityChecker: PlayIntegrityChecker +) { + suspend fun verifyProtectedAction(requestHash: String): Result { + val token = integrityChecker.requestIntegrityToken(requestHash).getOrElse { + return Result.failure(it) + } + return api.verifyIntegrity(token, requestHash) + } +} +``` + +### Server decode and verify checklist + +After your backend receives the integrity token string, call **`decodeIntegrityToken`** with a **service account** that has the **`playintegrity`** scope (see [Decrypt and verify the integrity verdict](https://developer.android.com/google/play/integrity/standard#decrypt-and-verify-the-integrity-verdict)). Validate the decrypted JSON **in order**: + +1. **`requestDetails`** - `requestPackageName` equals your application ID. For Standard requests, **`requestHash`** equals the value you computed for this action (same algorithm and canonical serialization as the client). Check **`timestampMillis`** is within a window you allow (reject stale tokens). For Classic requests, compare **`nonce`** to the value you issued for this request. +2. **`appIntegrity`** - `appRecognitionVerdict` (for example `PLAY_RECOGNIZED` vs `UNRECOGNIZED_VERSION`). +3. **`deviceIntegrity`** - `deviceRecognitionVerdict` labels (for example `MEETS_DEVICE_INTEGRITY`, optional labels if you opted in under Play Console). +4. **`accountDetails`** - `appLicensingVerdict` (for example `LICENSED` vs `UNLICENSED`). +5. **`environmentDetails`** - Only present if you enabled optional verdicts in Play Console; interpret **app access risk** and **Play Protect** per [Integrity verdicts](https://developer.android.com/google/play/integrity/verdicts). + +Repeated decryption of the **same** token can clear or weaken verdicts (Google documents replay protection). Issue one token per protected server request. + +### Standard API sequence (reference) + +```mermaid +sequenceDiagram + participant App + participant PlayServices as PlayIntegrity + participant Backend + participant GoogleAPI as GoogleDecode + App->>PlayServices: prepareIntegrityToken + PlayServices-->>App: StandardIntegrityTokenProvider + App->>PlayServices: request with requestHash + PlayServices-->>App: integrity token string + App->>Backend: HTTPS with token + Backend->>GoogleAPI: decodeIntegrityToken + GoogleAPI-->>Backend: verdict JSON +``` + +### Client errors and retries + +Use the official matrix: [Handle Play Integrity API error codes](https://developer.android.com/google/play/integrity/error-codes). + +- **Often retry with backoff** (transient): `NETWORK_ERROR`, `TOO_MANY_REQUESTS`, `GOOGLE_SERVER_UNAVAILABLE`, `CLIENT_TRANSIENT_ERROR`, `INTERNAL_ERROR`; follow Google guidance (initial delay, exponential backoff, cap attempts). +- **Usually fix environment or config** (not a blind retry): `API_NOT_AVAILABLE`, `PLAY_STORE_NOT_FOUND`, `PLAY_STORE_VERSION_OUTDATED`, `PLAY_SERVICES_NOT_FOUND`, `PLAY_SERVICES_VERSION_OUTDATED`, `CLOUD_PROJECT_NUMBER_IS_INVALID`, `CANNOT_BIND_TO_SERVICE` - prompt user to update Play Store or Play services, or fix the Cloud project number you pass from the engineer. +- **Standard only:** `INTEGRITY_TOKEN_PROVIDER_INVALID` - **invalidate the cached provider**, clear it, run **`warmUp()`** again, then retry the token request. +- **`REQUEST_HASH_TOO_LONG`** - shorten the digest input or hash to a fixed-length string before sending. + +Treat persistent failures after retries as **failed integrity** for that action and apply your tiered policy (do not assume success). + +### Remediation dialogs + +Google Play can show **in-app dialogs** so users fix licensing, Play services, or integrity issues. See [Remediation dialogs](https://developer.android.com/google/play/integrity/remediation). Requires Play Integrity library **1.3.0 or higher** for `showDialog` on token responses; **1.5.0 or higher** for `GET_INTEGRITY` / `GET_STRONG_INTEGRITY` style flows on **remediable** exceptions. + +- **Your server** decides whether to ask the client to show a dialog (for example after a bad verdict or a specific error code). +- **Your app** builds `StandardIntegrityDialogRequest` (or the Classic equivalent) with the **activity**, dialog **type code**, and the **token or exception** payload from the API. +- After the user closes the dialog, **request a fresh token**; for Standard API, **prepare the token provider again** (warm up) before the next integrity request, as documented on the remediation page. + +### Integrity Verdicts + +| Verdict | Meaning | +|---------------------------|------------------------------------------------------| +| `MEETS_DEVICE_INTEGRITY` | Real device with Google Play | +| `MEETS_BASIC_INTEGRITY` | Device may be rooted but passes basic checks | +| `MEETS_STRONG_INTEGRITY` | Genuine device, recent security patch, boot verified | +| `MEETS_VIRTUAL_INTEGRITY` | Running in an emulator recognized by Google Play | + +## Root & Emulator Detection + +### Relationship to Play Integrity + +Local root/emulator checks are supplementary signals only (telemetry, fraud hints, optional warnings, feature gating). They are easy to evade on modified devices. + +Required: +- Use server-verified Play Integrity tokens for login, payments, and sensitive operations (see [Device trust and abuse resistance](#device-trust-and-abuse-resistance) and [Play Integrity API](#play-integrity-api)). +- If both ship, never equate "root detected" with "Play Integrity failed"; route through tiered policy. + +Reference: [Play Integrity API overview](https://developer.android.com/google/play/integrity/overview). + +### Root Detection + +```kotlin +// core/data/security/RootDetector.kt +class RootDetector @Inject constructor() { + + fun isDeviceRooted(): Boolean { + return checkRootBinaries() || + checkSuExists() || + checkRootProperties() || + checkRootCloaking() || + checkTestKeys() + } + + private fun checkRootBinaries(): Boolean { + val paths = listOf( + "/system/bin/su", "/system/xbin/su", "/sbin/su", + "/data/local/xbin/su", "/data/local/bin/su", + "/system/sd/xbin/su", "/system/bin/failsafe/su", + "/data/local/su", "/su/bin/su", + "/system/app/Superuser.apk", + "/system/app/SuperSU.apk", + "/system/app/Kinguser.apk", + // Magisk + "/sbin/.magisk", "/cache/.disable_magisk", + "/dev/.magisk/mirror", + ) + return paths.any { File(it).exists() } + } + + private fun checkSuExists(): Boolean { + return try { + Runtime.getRuntime().exec("which su") + .inputStream.bufferedReader().readLine() != null + } catch (_: Exception) { + false + } + } + + private fun checkRootProperties(): Boolean { + val dangerousProps = mapOf( + "ro.debuggable" to "1", + "ro.secure" to "0" + ) + return dangerousProps.any { (key, value) -> + try { + val process = Runtime.getRuntime().exec("getprop $key") + val result = process.inputStream.bufferedReader().readLine()?.trim() + result == value + } catch (_: Exception) { + false + } + } + } + + private fun checkRootCloaking(): Boolean { + val cloakingPackages = listOf( + "com.devadvance.rootcloak", + "com.devadvance.rootcloakplus", + "de.robv.android.xposed.installer", + "com.saurik.substrate", + "com.zachspong.temprootremovejb", + "com.amphoras.hidemyroot", + "com.koushikdutta.superuser", + "eu.chainfire.supersu", + "com.topjohnwu.magisk" + ) + return cloakingPackages.any { pkg -> + try { + Runtime.getRuntime().exec("pm list packages $pkg") + .inputStream.bufferedReader().readLine()?.contains(pkg) == true + } catch (_: Exception) { + false + } + } + } + + private fun checkTestKeys(): Boolean { + val buildTags = Build.TAGS + return buildTags != null && buildTags.contains("test-keys") + } +} +``` + +### Emulator Detection + +```kotlin +// core/data/security/EmulatorDetector.kt +class EmulatorDetector @Inject constructor() { + + fun isEmulator(): Boolean { + return checkBuildProperties() || + checkHardware() || + checkSensors() + } + + private fun checkBuildProperties(): Boolean { + return (Build.FINGERPRINT.startsWith("generic") || + Build.FINGERPRINT.startsWith("unknown") || + Build.MODEL.contains("google_sdk") || + Build.MODEL.lowercase().contains("droid4x") || + Build.MODEL.contains("Emulator") || + Build.MODEL.contains("Android SDK built for") || + Build.MANUFACTURER.contains("Genymotion") || + Build.HARDWARE.contains("goldfish") || + Build.HARDWARE.contains("ranchu") || + Build.HARDWARE.contains("vbox86") || + Build.PRODUCT.contains("sdk") || + Build.PRODUCT.contains("vbox86p") || + Build.PRODUCT.contains("emulator") || + Build.PRODUCT.contains("simulator") || + Build.BOARD.lowercase().contains("nox") || + Build.BOOTLOADER.lowercase().contains("nox") || + Build.HARDWARE.lowercase().contains("nox") || + Build.PRODUCT.lowercase().contains("nox") || + Build.SERIAL.lowercase().contains("nox")) + } + + private fun checkHardware(): Boolean { + return try { + val cpuInfo = File("/proc/cpuinfo").readText() + cpuInfo.contains("hypervisor") || + cpuInfo.contains("QEMU") || + cpuInfo.contains("Goldfish") + } catch (_: Exception) { + false + } + } + + private fun checkSensors(): Boolean { + // Emulators typically have 0 or very few sensors + return try { + val sensorManager = android.hardware.SensorManager::class.java + false // Requires context; implement via DI + } catch (_: Exception) { + false + } + } +} +``` + +### Architecture Integration + +```kotlin +// core/data/security/SecurityChecker.kt +class SecurityChecker @Inject constructor( + private val rootDetector: RootDetector, + private val emulatorDetector: EmulatorDetector, + private val integrityChecker: PlayIntegrityChecker, + private val crashReporter: CrashReporter +) { + data class SecurityReport( + val isRooted: Boolean, + val isEmulator: Boolean, + val integrityVerdict: IntegrityVerdict? = null + ) + + suspend fun performSecurityCheck(): SecurityReport { + val isRooted = rootDetector.isDeviceRooted() + val isEmulator = emulatorDetector.isEmulator() + + if (isRooted) { + crashReporter.log("Security: Rooted device detected") + } + if (isEmulator) { + crashReporter.log("Security: Emulator detected") + } + + return SecurityReport( + isRooted = isRooted, + isEmulator = isEmulator + ) + } +} +``` + +### Handling Detection Results + +Don't crash or block users without good reason. Choose a response based on your app's risk level: + +| Risk Level | Rooted Device | Emulator | +|-------------------------|------------------------|---------------------| +| **Low** (news app) | Log warning | Allow | +| **Medium** (e-commerce) | Show warning, log | Block in production | +| **High** (banking) | Block with explanation | Block | + +```kotlin +@HiltViewModel +class SecurityViewModel @Inject constructor( + private val securityChecker: SecurityChecker +) : ViewModel() { + + private val _securityState = MutableStateFlow(SecurityState.Checking) + val securityState: StateFlow = _securityState.asStateFlow() + + init { + viewModelScope.launch { + val report = securityChecker.performSecurityCheck() + _securityState.value = when { + report.isRooted -> SecurityState.RootedDevice + report.isEmulator && !BuildConfig.DEBUG -> SecurityState.EmulatorDetected + else -> SecurityState.Secure + } + } + } +} +``` + +## Screenshot & Screen Recording Prevention + +### Prevent Screenshots (FLAG_SECURE) + +```kotlin +// In Activity +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Prevent screenshots and screen recording + if (!BuildConfig.DEBUG) { + window.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE + ) + } + } +} +``` + +### Per-Screen Screenshot Prevention in Compose + +For more granular control (e.g., only block on sensitive screens): + +```kotlin +@Composable +fun SecureScreen(content: @Composable () -> Unit) { + val activity = LocalContext.current as? Activity + + DisposableEffect(Unit) { + activity?.window?.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE + ) + onDispose { + activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + + content() +} + +// Usage +@Composable +fun PaymentScreen() { + SecureScreen { + Column { + Text("Enter card details") + // Payment form + } + } +} +``` + +### Preventing Recent Apps Thumbnail + +`FLAG_SECURE` also prevents the app from appearing in the recent apps screenshot. + +## Secure Database (Room 3) + +Room 3 requires a [`SQLiteDriver`](https://developer.android.com/kotlin/multiplatform/sqlite#sqlite-driver) on [`Room.databaseBuilder`](https://developer.android.com/jetpack/androidx/releases/room3). It does **not** support `SupportSQLiteOpenHelper.Factory` or `openHelperFactory` (removed with SupportSQLite). + +### Building the database (driver required) + +```kotlin +// core/database/di/DatabaseModule.kt +import android.content.Context +import androidx.room3.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DatabaseModule { + + @Provides + @Singleton + fun provideDatabase(@ApplicationContext context: Context): AppDatabase { + return Room.databaseBuilder( + context = context, + name = "app_database", + ) + .setDriver(BundledSQLiteDriver()) + .fallbackToDestructiveMigration() + .build() + } +} +``` + +`BundledSQLiteDriver` matches the `sqlite-bundled` dependency added by the `app.android.room` convention (see `assets/libs.versions.toml.template`). + +### SQLCipher / full-database encryption + +The Room 2 pattern `SupportOpenHelperFactory` + `openHelperFactory` does **not** apply to Room 3. To encrypt the whole database, follow **SQLCipher** (or your vendor) documentation for an **`SQLiteDriver`** (or supported integration) compatible with **`androidx.sqlite`**, then pass it to `.setDriver(...)`. The [`room3-sqlite-wrapper`](https://developer.android.com/jetpack/androidx/releases/room3) artifact is for bridging **legacy `SupportSQLite` call sites**, not for replacing a proper driver on the main `RoomDatabase` builder. See [Migrate from SupportSQLite](https://developer.android.com/kotlin/multiplatform/room#migrate) and [Room 3 release notes](https://developer.android.com/jetpack/androidx/releases/room3). + +### Sensitive Field Encryption + +For encrypting specific fields (when full-database encryption is too heavy): + +```kotlin +import androidx.room3.ColumnInfo +import androidx.room3.Entity +import androidx.room3.PrimaryKey + +@Entity(tableName = "users") +data class UserEntity( + @PrimaryKey val id: String, + val name: String, + @ColumnInfo(name = "encrypted_ssn") + val encryptedSsn: ByteArray, // Encrypted with AES-GCM + @ColumnInfo(name = "ssn_iv") + val ssnIv: ByteArray // IV for decryption +) + +// Repository handles encryption/decryption +class UserRepositoryImpl @Inject constructor( + private val userDao: UserDao, + private val encryption: AesGcmEncryption +) : UserRepository { + private val key = encryption.getOrCreateKey("user_data_key") + + override suspend fun saveUser(user: User) { + val encrypted = encryption.encrypt(user.ssn.toByteArray(), key) + val iv = encrypted.copyOfRange(0, 12) + val ciphertext = encrypted.copyOfRange(12, encrypted.size) + + userDao.insert(UserEntity( + id = user.id, + name = user.name, + encryptedSsn = ciphertext, + ssnIv = iv + )) + } +} +``` + +## Secure Clipboard + +### Prevent Clipboard Leaks + +```kotlin +// For sensitive fields, set clipboard to expire +@Composable +fun SensitiveTextField( + value: String, + onValueChange: (String) -> Unit +) { + val clipboardManager = LocalClipboardManager.current + + OutlinedTextField( + value = value, + onValueChange = onValueChange, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + autoCorrectEnabled = false + ) + ) +} +``` + +### Android 13+ Clipboard Auto-Clear + +On Android 13+ (API 33), sensitive clipboard content is automatically cleared after a timeout. For older versions, flag the content: + +```kotlin +if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val clipData = ClipData.newPlainText("", sensitiveText) + val clipDescription = clipData.description + val extras = PersistableBundle().apply { + putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) + } + clipDescription.extras = extras + clipboardManager.setPrimaryClip(clipData) +} +``` + +## WebView Security + +### Secure WebView Configuration + +```kotlin +@Composable +fun SecureWebView(url: String) { + AndroidView( + factory = { context -> + WebView(context).apply { + settings.apply { + javaScriptEnabled = false // Enable only if needed + allowFileAccess = false + allowContentAccess = false + domStorageEnabled = false + setSupportMultipleWindows(false) + javaScriptCanOpenWindowsAutomatically = false + + // Disable geolocation + setGeolocationEnabled(false) + + // Disable mixed content + mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW + + // Disable cache for sensitive content + cacheMode = WebSettings.LOAD_NO_CACHE + } + + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest? + ): Boolean { + val requestUrl = request?.url?.toString() ?: return true + // Only allow your domain + return !requestUrl.startsWith("https://yourdomain.com") + } + } + } + }, + update = { webView -> webView.loadUrl(url) } + ) +} +``` + +### Avoid `addJavascriptInterface` Attack Surface + +If JavaScript must be enabled, avoid `addJavascriptInterface()` as it exposes your app to XSS attacks. Use `evaluateJavascript()` for controlled communication instead. + +## Content Provider Security + +### Restrict Content Provider Access + +```xml + + +``` + +### FileProvider for Secure File Sharing + +```xml + + + +``` + +```xml + + + + + +``` + +### Forward-compatible URI grants (Android 18 prep) + +Required: pass `Intent.FLAG_GRANT_READ_URI_PERMISSION` (or `FLAG_GRANT_WRITE_URI_PERMISSION`) explicitly on every cross-process Intent that carries an `EXTRA_STREAM` or `EXTRA_OUTPUT` URI - `ACTION_SEND`, `ACTION_SEND_MULTIPLE`, `IMAGE_CAPTURE`. + +```kotlin +val sendIntent = Intent(Intent.ACTION_SEND).apply { + type = "image/jpeg" + putExtra(Intent.EXTRA_STREAM, fileUri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) +} +startActivity(Intent.createChooser(sendIntent, null)) +``` + +Forbidden: relying on the system to infer URI permission from `EXTRA_STREAM` or `EXTRA_OUTPUT` alone. + +## ProGuard / R8 Hardening + +Use `assets/proguard-rules.pro.template` as the source of truth for all keep rules. It includes security-specific sections: + +- **Log stripping** - removes `Log.v/d/i/w` calls in release builds +- **Crypto/security class preservation** - keeps `core.data.crypto.**` and `core.data.security.**` +- **Obfuscation hardening** - `repackageclasses`, `allowaccessmodification` +- **Crash report readability** - `SourceFile,LineNumberTable` attributes preserved +- **Mapping file upload** - Firebase and Sentry Gradle plugins handle this automatically + +See [gradle-setup.md](/references/gradle-setup.md#r8--proguard-configuration) for build configuration and debugging shrunk builds. + +### Manifest Security + +```xml + + + + + + + + + + +``` + +### Data Extraction Rules (API 31+) + +```xml + + + + + + + + + + + + +``` + +## CI/CD Security + +Route AAB defaults, Play tracks, staged rollout, and upload automation through [android-ci-cd.md](android-ci-cd.md). + +### Secrets Management + +```yaml +# .github/workflows/build.yml +env: + KEYSTORE_FILE: ${{ secrets.KEYSTORE_FILE }} + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} +``` + +**Never commit:** +- `*.jks` or `*.keystore` files +- `google-services.json` with production keys +- `sentry.properties` with auth tokens +- Any `.env` files +- API keys in source code + +### .gitignore Entries + +```gitignore +# Signing +*.jks +*.keystore +signing.properties + +# API keys +google-services.json +sentry.properties +local.properties + +# Build artifacts +/build/ +*.apk +*.aab +``` + +### Static Analysis in CI + +```yaml +# .github/workflows/security.yml +name: Security Checks + +on: [pull_request] + +jobs: + security: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check for hardcoded secrets + run: | + if grep -rn "AIza\|sk_live\|-----BEGIN" --include="*.kt" --include="*.xml" app/; then + echo "Potential secrets found in source code!" + exit 1 + fi + + - name: Run Detekt security rules + run: ./gradlew detekt + + - name: Check dependencies for vulnerabilities + run: ./gradlew dependencyCheckAnalyze +``` + +## Security Checklist + +Use this checklist for every release: + +### Network +- [ ] HTTPS enforced for all endpoints +- [ ] Certificate pinning configured for critical APIs +- [ ] Network security config blocks cleartext traffic +- [ ] TLS 1.2+ enforced + +### Data at Rest +- [ ] Auth tokens in `EncryptedSharedPreferences` +- [ ] Sensitive database fields encrypted (or full SQLCipher) +- [ ] No sensitive data in logs (`Log.d`, etc.) +- [ ] Cloud backup excludes sensitive data (`data_extraction_rules.xml`) +- [ ] `android:allowBackup="false"` in manifest + +### Authentication +- [ ] BiometricPrompt for sensitive actions +- [ ] Credential Manager / passkeys or other sign-in flows aligned with backend (where applicable) +- [ ] No hardware IDs (IMEI, MAC, serial) used for tracking; Advertising ID only where policy allows +- [ ] Session timeout implemented +- [ ] Re-authentication for critical operations (payment, password change) + +### Privacy and Play policy +- [ ] Play Console **Data safety** form matches actual SDK and app behavior +- [ ] Privacy policy URL current and linked from store listing / in-app as required +- [ ] User data deletion or export path documented where required + +### App Hardening +- [ ] R8/ProGuard enabled for release builds +- [ ] Log stripping in release builds +- [ ] High-risk apps: local root/emulator checks only as **supplement** (telemetry or soft warnings); **do not** rely on them alone for protecting APIs if Play Integrity is available +- [ ] `FLAG_SECURE` on sensitive screens +- [ ] All activities `android:exported="false"` except launcher +- [ ] Content providers not exported unless needed +- [ ] WebView JavaScript disabled unless required + +### Build & Deploy +- [ ] Signing keys not in version control +- [ ] API keys not hardcoded +- [ ] ProGuard mapping files uploaded to crash reporter +- [ ] Dependency vulnerability scanning in CI + +### Device Security +- [ ] Play Integrity for high-risk apps: **linked Cloud project** in Play Console; **Cloud project number** in app config; **`warmUp()`** before first sensitive use where applicable +- [ ] **Standard** API: **`requestHash`** binding; **Classic** API: **`nonce`** per Google rules; server calls **`decodeIntegrityToken`** and validates **`requestDetails`** before other verdicts +- [ ] **Tiered** enforcement and **gradual** rollout (telemetry before hard blocks); **remediation** path for recoverable integrity failures where product allows +- [ ] Keystore-backed key generation for device-bound or high-value crypto where designed +- [ ] StrongBox used when available + +## Rules + +Required: +- Server is the trust boundary; client never makes the final authorization decision. +- Layer controls (defense in depth); fail closed on errors. +- Request the minimum permission set; encrypt sensitive data at rest and in transit. +- Use Android Keystore over software-managed keys; require StrongBox where available. +- For high-value actions: Play Integrity (server-decoded) with `requestHash`/`nonce` binding + tiered backend policy. +- Track CVEs in dependencies; run security checks in CI. + +Forbidden: +- Logging tokens, PII, or any sensitive payload. +- Treating local root/emulator checks as authoritative. +- Hardcoding API keys, signing material, or secrets in source. + +Reference: [Android Security Tips](https://developer.android.com/privacy-and-security/security-tips). Cross-links: [crashlytics.md](/references/crashlytics.md), [android-permissions.md](/references/android-permissions.md), [gradle-setup.md](/references/gradle-setup.md), [architecture.md](/references/architecture.md), [android-data-sync.md](/references/android-data-sync.md), [android-strictmode.md](/references/android-strictmode.md). diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-strictmode.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-strictmode.md new file mode 100644 index 000000000..67a69ff14 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-strictmode.md @@ -0,0 +1,228 @@ +# Android StrictMode (Compose + Multi-Module) + +StrictMode is a three-tier guardrail. Required in debug builds; optional with `penaltyListener` in production. + +1. Classic thread/VM checks. +2. Compose compiler stability diagnostics. +3. CI guardrails. + +## 1) Classic StrictMode (Thread + VM) + +Initialize in `Application` for debug builds. Keep it app-level only. + +```kotlin +class MyApplication : Application() { + override fun onCreate() { + super.onCreate() + + if (BuildConfig.DEBUG) { + StrictMode.setThreadPolicy( + StrictMode.ThreadPolicy.Builder() + .detectAll() + .penaltyLog() + .build() + ) + StrictMode.setVmPolicy( + StrictMode.VmPolicy.Builder() + .detectLeakedSqlLiteObjects() // Detects SQLite cursor objects that have not been closed. + .detectLeakedClosableObjects() // Detects when Closeable objects are not closed. + // .detectActivityLeaks() // Detects Activity object leaks. + // .detectFileUriExposure() // Detects when a file:// URI is exposed outside the app. + // .detectCleartextNetwork() // Detects unencrypted network traffic (HTTP instead of HTTPS). + // .detectUnsafeIntentLaunch() // Detects unsafe intent launches. + // or .detectAll() for all VM policy checks + .penaltyLog() + .build() + ) + } else { + // Production: silent collection + StrictMode.setVmPolicy( + StrictMode.VmPolicy.Builder() + .detectLeakedClosableObjects() + .detectActivityLeaks() + // Use penaltyListener to ship violations to your crash reporter + .penaltyListener(mainExecutor) { violation -> + // Ship to Firebase Crashlytics, Sentry, etc. + FirebaseCrashlytics.getInstance().recordException(violation) + } + // Or use penaltyDropBox() for system-level logging + // .penaltyDropBox() + .build() + ) + } + } +} +``` + +**Production penalties:** +- `penaltyListener(executor, listener)` - Custom handling, ship to crash reporters +- `penaltyDropBox()` - Logs to system DropBoxManager (accessible via `adb shell dumpsys dropbox`) +- Avoid `penaltyDeath()` or `penaltyLog()` in production (crashes users or spam logs) + +**When to use production StrictMode:** +- Collecting violation data from beta testers or internal builds +- Monitoring memory leaks in production (sample 1-5% of users) +- Verifying fixes are working in the wild + +**See also:** `references/crashlytics.md` for crash reporter integration. + +### ThreadPolicy Options + +- `detectAll()` - Enables all thread policy checks. +- `detectDiskReads()` - Detects reading data from disk. +- `detectDiskWrites()` - Detects writing data to disk. +- `detectNetwork()` - Detects network operations (HTTP requests, etc.). +- `detectCustomSlowCalls()` - Detects slow operations (e.g., SQLite queries). +- `permitAll()` - Disables all thread policy detections. + +### VmPolicy Options + +- `detectAll()` - Enables all VM policy checks. +- `detectActivityLeaks()` - Detects Activity object leaks. +- `detectLeakedClosableObjects()` - Detects when Closeable objects are not closed properly. +- `detectLeakedSqlLiteObjects()` - Detects SQLite cursor objects that have not been closed. +- `detectFileUriExposure()` - Detects when a file:// URI is exposed outside the app. +- `detectCleartextNetwork()` - Detects unencrypted network traffic (HTTP instead of HTTPS). +- `detectUnsafeIntentLaunch()` - Detects unsafe intent launches. + +### Penalty Options + +- `penaltyLog()` - Logs violations to Logcat. By Default, use this option. +- `penaltyDeath()` - Crashes the app on violation (useful for catching issues during development). +- `penaltyFlashScreen()` - Flashes the screen on violation (visual feedback). +- `penaltyDropBox()` - Logs violations to DropBoxManager for system-level tracking. + +## 2) Compose Stability Guardrails + +Enable compiler reports + metrics for Compose stability diagnostics. + +**Best practice:** Gate metrics/reports behind Gradle properties to avoid generating them during normal builds (they can be large and slow down compilation). + +```kotlin +// module build.gradle.kts +composeCompiler { + // Only generate metrics when explicitly requested + val enableMetrics = project.providers + .gradleProperty("enableComposeCompilerMetrics") + .orNull?.toBoolean() ?: false + if (enableMetrics) { + metricsDestination = layout.buildDirectory.dir("compose-metrics") + } + + // Only generate reports when explicitly requested + val enableReports = project.providers + .gradleProperty("enableComposeCompilerReports") + .orNull?.toBoolean() ?: false + if (enableReports) { + reportsDestination = layout.buildDirectory.dir("compose-reports") + } + + enableStrongSkippingMode = true + stabilityConfigurationFile = rootProject.file("stability_config.conf") +} +``` + +**Generating reports:** +```bash +# Generate metrics +./gradlew assembleDebug -PenableComposeCompilerMetrics=true + +# Generate reports +./gradlew assembleDebug -PenableComposeCompilerReports=true + +# Generate both +./gradlew assembleDebug \ + -PenableComposeCompilerMetrics=true \ + -PenableComposeCompilerReports=true +``` + +Reports will be in `build/compose-metrics/` and `build/compose-reports/` for each module. + +### Stability Configuration File + +Create `stability_config.conf` in your **root project directory** to mark external types as stable. + +This follows Google's recommended filename convention (see [Compose Compiler documentation](https://developer.android.com/develop/ui/compose/performance/stability/fix#configuration-file)). + +Use this when third-party or generated types are immutable in fact but lack `@Stable`/`@Immutable`. Without an entry here, Compose marks them unstable and recomposes unnecessarily. + +`stability_config.conf`: + +**Common patterns to include:** + +`compose-stability.conf`: +```text +// Kotlin immutable collections (mark all as stable) +kotlin.collections.* + +// Kotlinx immutable collections +org.jetbrains.kotlinx.collections.immutable.* + +// Third-party library models +com.external.library.models.* + +// Generated data classes (e.g., from protobuf, Room) +com.example.data.generated.* +``` + +**When to add types:** +- External library data classes that are immutable but not annotated +- Generated code (Room entities, proto messages) that you control immutability for +- Sealed hierarchies from dependencies +- Value classes from third-party SDKs + +**When NOT to add types:** +- Your own code (annotate directly with `@Stable`/`@Immutable`) +- Mutable types (this will cause bugs!) +- Types you're unsure about (verify immutability first) + +### Analyzing Compose Metrics + +After building, review the generated metrics: + +```bash +# View unstable composables +cat build/compose_reports/module_composables.txt + +# View detailed stability info +cat build/compose_reports/module_classes.txt +``` + +Look for: +- `unstable` parameters (mutable or unrecognized types) +- `skippable: false` composables (recompose unnecessarily) +- High `groups` counts (complex composition structure) + +## 3) CI Guardrails (Optional) + +See: `references/android-performance.md` → "Compose Stability Validation (Optional)". + +## Uploading StrictMode Signals to Crash Reporters + +**For debug builds:** StrictMode uses `.penaltyLog()` to emit violations to Logcat. + +**For production/beta builds:** Use `.penaltyListener()` to ship violations directly to crash reporters: + +```kotlin +StrictMode.setVmPolicy( + StrictMode.VmPolicy.Builder() + .detectLeakedClosableObjects() + .penaltyListener(mainExecutor) { violation -> + // Ship to Firebase Crashlytics: + FirebaseCrashlytics.getInstance().recordException(violation) + // Or: Sentry.captureException(violation) + } + .build() +) +``` + +Alternatively, enable log/breadcrumb capture in your crash reporter to automatically collect `penaltyLog()` output: + +- **Sentry**: enable logs in init (`options.logs.isEnabled = true`). +- **Firebase Crashlytics**: use Analytics + Crashlytics logging for breadcrumbs. + +See `references/crashlytics.md` for provider initialization and wiring. + +## References + +- https://developer.android.com/reference/android/os/StrictMode diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-theming.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-theming.md new file mode 100644 index 000000000..1c97b88a7 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/android-theming.md @@ -0,0 +1,2118 @@ +# Android Theming + +Material Design 3 theming: dynamic color, custom color schemes, typography scales, shape theming, dark/light mode. + +All Kotlin code must align with `references/kotlin-patterns.md`. Theme usage in composables: `references/compose-patterns.md`. Color contrast targets: `references/android-accessibility.md`. + +## Table of Contents + +- [Material 3 Theme System](#material-3-theme-system) +- [Color Schemes](#color-schemes) +- [Color Pairing Rules](#color-pairing-rules) +- [`outline` vs `outlineVariant`](#outline-vs-outlinevariant) +- [Surface Container Hierarchy](#surface-container-hierarchy) +- [Tonal Elevation vs Shadows](#tonal-elevation-vs-shadows) +- [Dynamic Color (Material You)](#dynamic-color-material-you) +- [User Contrast Preference (Android 14+)](#user-contrast-preference-android-14) +- [Typography Scales](#typography-scales) +- [Shape Theming](#shape-theming) +- [Material 3 Expressive](#material-3-expressive) +- [Dark/Light Mode Switching](#darklight-mode-switching) +- [Theme Preferences](#theme-preferences) +- [Custom Theme Attributes](#custom-theme-attributes) + - [Brand Color Harmonization](#brand-color-harmonization) +- [Scoped Themes](#scoped-themes) +- [Architecture Integration](#architecture-integration) +- [Testing](#testing) +- [Layout Spacing and Component Dimensions](#layout-spacing-and-component-dimensions) +- [Reserved Resource Names](#reserved-resource-names) +- [Visual Style by App Category](#visual-style-by-app-category) +- [Theme routing](#theme-routing) + +## Material 3 Theme System + +Material 3 uses a three-layer system: color scheme, typography, and shapes. + +### Basic Theme Setup + +```kotlin +// core/ui/theme/Theme.kt +package com.example.core.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +@Composable +fun AppTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = AppTypography, + shapes = AppShapes, + content = content + ) +} +``` + +### Using in MainActivity + +Edge-to-edge is mandatory on API 36. Use `Scaffold` which handles system bar insets automatically. + +```kotlin +// app/MainActivity.kt +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + enableEdgeToEdge() + + setContent { + AppTheme { + Scaffold( + modifier = Modifier.fillMaxSize() + ) { innerPadding -> + MainNavigation( + modifier = Modifier.padding(innerPadding) + ) + } + } + } + } +} +``` + +## Color Schemes + +### Full Color Role Reference (M3) + +Material 3 defines ~40 semantic color roles. Use these on `MaterialTheme.colorScheme.*` instead of raw `Color(...)` so themes, dark mode, dynamic color, and user contrast all keep working. + +**Accent roles** (3 groups: primary, secondary, tertiary) + +| Role | `colorScheme.*` | Use for | +|-------------------------------------------|----------------------|------------------------------------------------------| +| Primary | `primary` | High-emphasis fills, FAB, primary button | +| On Primary | `onPrimary` | Text/icons on `primary` | +| Primary Container | `primaryContainer` | Standout container fill (selected chip, hero card) | +| On Primary Container | `onPrimaryContainer` | Text/icons on `primaryContainer` | +| Secondary / On / Container / On Container | `secondary*` | Less prominent accents (tonal buttons, filter chips) | +| Tertiary / On / Container / On Container | `tertiary*` | Contrasting accents (badges, complementary surfaces) | + +**Error roles** (do not change with dynamic color) + +| Role | `colorScheme.*` | Use for | +|--------------------|--------------------|----------------------------------| +| Error | `error` | Destructive action, validation | +| On Error | `onError` | Text/icons on `error` | +| Error Container | `errorContainer` | Error banner / inline error fill | +| On Error Container | `onErrorContainer` | Text/icons on `errorContainer` | + +**Surface roles** (the modern depth system - prefer over `background`) + +| Role | `colorScheme.*` | Use for | +|---------------------------|---------------------------|---------------------------------------------------------------| +| Surface | `surface` | Default screen background | +| On Surface | `onSurface` | Primary text/icons on any surface | +| On Surface Variant | `onSurfaceVariant` | Lower-emphasis text/icons on surface | +| Surface Container Lowest | `surfaceContainerLowest` | Lowest-tone container (rarely used) | +| Surface Container Low | `surfaceContainerLow` | Cards in flow, low-emphasis containers | +| Surface Container | `surfaceContainer` | Default container (nav bar, persistent panels) | +| Surface Container High | `surfaceContainerHigh` | Menus, scrolled top app bar | +| Surface Container Highest | `surfaceContainerHighest` | Filled cards, highest-emphasis nested container | +| Surface Dim | `surfaceDim` | Always-dimmest surface (both themes) | +| Surface Bright | `surfaceBright` | Always-brightest surface (both themes) | +| Surface Tint | `surfaceTint` | Tonal-elevation tint (set by `Surface(tonalElevation = ...)`) | + +**Inverse roles** (for elements that contrast against the surrounding UI, e.g. snackbars) + +| Role | `colorScheme.*` | Use for | +|--------------------|--------------------|--------------------------------------| +| Inverse Surface | `inverseSurface` | Snackbar background, inverted toast | +| Inverse On Surface | `inverseOnSurface` | Text on `inverseSurface` | +| Inverse Primary | `inversePrimary` | Actionable text on `inverseSurface` | + +**Outline roles** + +| Role | `colorScheme.*` | Use for | +|-----------------|------------------|----------------------------------------------------------| +| Outline | `outline` | Interactive boundaries (text-field borders, focus rings) | +| Outline Variant | `outlineVariant` | Decorative dividers, card borders | + +**Fixed accent roles** (same color in light **and** dark - keep brand identity inside scoped surfaces) + +| Role | `colorScheme.*` | Use for | +|------------------------------------------------------|-------------------------|--------------------------------------------------| +| Primary Fixed | `primaryFixed` | Branded chip / badge that must not flip on theme | +| Primary Fixed Dim | `primaryFixedDim` | Dimmer companion to `primaryFixed` | +| On Primary Fixed | `onPrimaryFixed` | Text/icons on `primaryFixed` | +| On Primary Fixed Variant | `onPrimaryFixedVariant` | Lower-emphasis text on `primaryFixed` | +| (same shape for `secondaryFixed*`, `tertiaryFixed*`) | - | Brand-locked secondary/tertiary surfaces | + +Fixed roles do not adapt to theme - only use them where preserving identity matters more than contrast adjustment. + +**Scrim** + +| Role | `colorScheme.*` | Use for | +|-------|-----------------|------------------------------------------------| +| Scrim | `scrim` | Modal backdrops behind dialogs / bottom sheets | + +`background` / `onBackground` still exist for backwards compatibility; in new code prefer `surface` / `onSurface`. + +### Default Light and Dark Schemes + +Material 3 uses semantic color roles instead of hardcoded colors. + +```kotlin +// core/ui/theme/Color.kt +package com.example.core.ui.theme + +import androidx.compose.ui.graphics.Color + +// Light theme colors +val md_theme_light_primary = Color(0xFF6750A4) +val md_theme_light_onPrimary = Color(0xFFFFFFFF) +val md_theme_light_primaryContainer = Color(0xFFEADDFF) +val md_theme_light_onPrimaryContainer = Color(0xFF21005D) +val md_theme_light_secondary = Color(0xFF625B71) +val md_theme_light_onSecondary = Color(0xFFFFFFFF) +val md_theme_light_secondaryContainer = Color(0xFFE8DEF8) +val md_theme_light_onSecondaryContainer = Color(0xFF1D192B) +val md_theme_light_tertiary = Color(0xFF7D5260) +val md_theme_light_onTertiary = Color(0xFFFFFFFF) +val md_theme_light_tertiaryContainer = Color(0xFFFFD8E4) +val md_theme_light_onTertiaryContainer = Color(0xFF31111D) +val md_theme_light_error = Color(0xFFB3261E) +val md_theme_light_errorContainer = Color(0xFFF9DEDC) +val md_theme_light_onError = Color(0xFFFFFFFF) +val md_theme_light_onErrorContainer = Color(0xFF410E0B) +val md_theme_light_background = Color(0xFFFFFBFE) +val md_theme_light_onBackground = Color(0xFF1C1B1F) +val md_theme_light_surface = Color(0xFFFFFBFE) +val md_theme_light_onSurface = Color(0xFF1C1B1F) +val md_theme_light_surfaceVariant = Color(0xFFE7E0EC) +val md_theme_light_onSurfaceVariant = Color(0xFF49454F) +val md_theme_light_outline = Color(0xFF79747E) +val md_theme_light_inverseOnSurface = Color(0xFFF4EFF4) +val md_theme_light_inverseSurface = Color(0xFF313033) +val md_theme_light_inversePrimary = Color(0xFFD0BCFF) +val md_theme_light_surfaceTint = Color(0xFF6750A4) +val md_theme_light_outlineVariant = Color(0xFFCAC4D0) +val md_theme_light_scrim = Color(0xFF000000) + +// Surface containers (M3) - tonal hierarchy for nested surfaces +val md_theme_light_surfaceContainerLowest = Color(0xFFFFFFFF) +val md_theme_light_surfaceContainerLow = Color(0xFFF7F2FA) +val md_theme_light_surfaceContainer = Color(0xFFF3EDF7) +val md_theme_light_surfaceContainerHigh = Color(0xFFECE6F0) +val md_theme_light_surfaceContainerHighest = Color(0xFFE6E0E9) +val md_theme_light_surfaceDim = Color(0xFFDED8E1) +val md_theme_light_surfaceBright = Color(0xFFFEF7FF) + +// Fixed accent roles (M3) - same color in light and dark +val md_theme_primaryFixed = Color(0xFFEADDFF) +val md_theme_primaryFixedDim = Color(0xFFD0BCFF) +val md_theme_onPrimaryFixed = Color(0xFF21005D) +val md_theme_onPrimaryFixedVariant = Color(0xFF4F378B) +val md_theme_secondaryFixed = Color(0xFFE8DEF8) +val md_theme_secondaryFixedDim = Color(0xFFCCC2DC) +val md_theme_onSecondaryFixed = Color(0xFF1D192B) +val md_theme_onSecondaryFixedVariant = Color(0xFF4A4458) +val md_theme_tertiaryFixed = Color(0xFFFFD8E4) +val md_theme_tertiaryFixedDim = Color(0xFFEFB8C8) +val md_theme_onTertiaryFixed = Color(0xFF31111D) +val md_theme_onTertiaryFixedVariant = Color(0xFF633B48) + +// Dark theme colors +val md_theme_dark_primary = Color(0xFFD0BCFF) +val md_theme_dark_onPrimary = Color(0xFF381E72) +val md_theme_dark_primaryContainer = Color(0xFF4F378B) +val md_theme_dark_onPrimaryContainer = Color(0xFFEADDFF) +val md_theme_dark_secondary = Color(0xFFCCC2DC) +val md_theme_dark_onSecondary = Color(0xFF332D41) +val md_theme_dark_secondaryContainer = Color(0xFF4A4458) +val md_theme_dark_onSecondaryContainer = Color(0xFFE8DEF8) +val md_theme_dark_tertiary = Color(0xFFEFB8C8) +val md_theme_dark_onTertiary = Color(0xFF492532) +val md_theme_dark_tertiaryContainer = Color(0xFF633B48) +val md_theme_dark_onTertiaryContainer = Color(0xFFFFD8E4) +val md_theme_dark_error = Color(0xFFF2B8B5) +val md_theme_dark_errorContainer = Color(0xFF8C1D18) +val md_theme_dark_onError = Color(0xFF601410) +val md_theme_dark_onErrorContainer = Color(0xFFF9DEDC) +val md_theme_dark_background = Color(0xFF1C1B1F) +val md_theme_dark_onBackground = Color(0xFFE6E1E5) +val md_theme_dark_surface = Color(0xFF1C1B1F) +val md_theme_dark_onSurface = Color(0xFFE6E1E5) +val md_theme_dark_surfaceVariant = Color(0xFF49454F) +val md_theme_dark_onSurfaceVariant = Color(0xFFCAC4D0) +val md_theme_dark_outline = Color(0xFF938F99) +val md_theme_dark_inverseOnSurface = Color(0xFF1C1B1F) +val md_theme_dark_inverseSurface = Color(0xFFE6E1E5) +val md_theme_dark_inversePrimary = Color(0xFF6750A4) +val md_theme_dark_surfaceTint = Color(0xFFD0BCFF) +val md_theme_dark_outlineVariant = Color(0xFF49454F) +val md_theme_dark_scrim = Color(0xFF000000) + +// Surface containers (M3) - tonal hierarchy for nested surfaces +val md_theme_dark_surfaceContainerLowest = Color(0xFF0F0D13) +val md_theme_dark_surfaceContainerLow = Color(0xFF1D1B20) +val md_theme_dark_surfaceContainer = Color(0xFF211F26) +val md_theme_dark_surfaceContainerHigh = Color(0xFF2B2930) +val md_theme_dark_surfaceContainerHighest = Color(0xFF36343B) +val md_theme_dark_surfaceDim = Color(0xFF141218) +val md_theme_dark_surfaceBright = Color(0xFF3B383E) + +val LightColorScheme = lightColorScheme( + primary = md_theme_light_primary, + onPrimary = md_theme_light_onPrimary, + primaryContainer = md_theme_light_primaryContainer, + onPrimaryContainer = md_theme_light_onPrimaryContainer, + secondary = md_theme_light_secondary, + onSecondary = md_theme_light_onSecondary, + secondaryContainer = md_theme_light_secondaryContainer, + onSecondaryContainer = md_theme_light_onSecondaryContainer, + tertiary = md_theme_light_tertiary, + onTertiary = md_theme_light_onTertiary, + tertiaryContainer = md_theme_light_tertiaryContainer, + onTertiaryContainer = md_theme_light_onTertiaryContainer, + error = md_theme_light_error, + errorContainer = md_theme_light_errorContainer, + onError = md_theme_light_onError, + onErrorContainer = md_theme_light_onErrorContainer, + background = md_theme_light_background, + onBackground = md_theme_light_onBackground, + surface = md_theme_light_surface, + onSurface = md_theme_light_onSurface, + surfaceVariant = md_theme_light_surfaceVariant, + onSurfaceVariant = md_theme_light_onSurfaceVariant, + outline = md_theme_light_outline, + inverseOnSurface = md_theme_light_inverseOnSurface, + inverseSurface = md_theme_light_inverseSurface, + inversePrimary = md_theme_light_inversePrimary, + surfaceTint = md_theme_light_surfaceTint, + outlineVariant = md_theme_light_outlineVariant, + scrim = md_theme_light_scrim, + surfaceContainerLowest = md_theme_light_surfaceContainerLowest, + surfaceContainerLow = md_theme_light_surfaceContainerLow, + surfaceContainer = md_theme_light_surfaceContainer, + surfaceContainerHigh = md_theme_light_surfaceContainerHigh, + surfaceContainerHighest = md_theme_light_surfaceContainerHighest, + surfaceDim = md_theme_light_surfaceDim, + surfaceBright = md_theme_light_surfaceBright, + primaryFixed = md_theme_primaryFixed, + primaryFixedDim = md_theme_primaryFixedDim, + onPrimaryFixed = md_theme_onPrimaryFixed, + onPrimaryFixedVariant = md_theme_onPrimaryFixedVariant, + secondaryFixed = md_theme_secondaryFixed, + secondaryFixedDim = md_theme_secondaryFixedDim, + onSecondaryFixed = md_theme_onSecondaryFixed, + onSecondaryFixedVariant = md_theme_onSecondaryFixedVariant, + tertiaryFixed = md_theme_tertiaryFixed, + tertiaryFixedDim = md_theme_tertiaryFixedDim, + onTertiaryFixed = md_theme_onTertiaryFixed, + onTertiaryFixedVariant = md_theme_onTertiaryFixedVariant +) + +val DarkColorScheme = darkColorScheme( + primary = md_theme_dark_primary, + onPrimary = md_theme_dark_onPrimary, + primaryContainer = md_theme_dark_primaryContainer, + onPrimaryContainer = md_theme_dark_onPrimaryContainer, + secondary = md_theme_dark_secondary, + onSecondary = md_theme_dark_onSecondary, + secondaryContainer = md_theme_dark_secondaryContainer, + onSecondaryContainer = md_theme_dark_onSecondaryContainer, + tertiary = md_theme_dark_tertiary, + onTertiary = md_theme_dark_onTertiary, + tertiaryContainer = md_theme_dark_tertiaryContainer, + onTertiaryContainer = md_theme_dark_onTertiaryContainer, + error = md_theme_dark_error, + errorContainer = md_theme_dark_errorContainer, + onError = md_theme_dark_onError, + onErrorContainer = md_theme_dark_onErrorContainer, + background = md_theme_dark_background, + onBackground = md_theme_dark_onBackground, + surface = md_theme_dark_surface, + onSurface = md_theme_dark_onSurface, + surfaceVariant = md_theme_dark_surfaceVariant, + onSurfaceVariant = md_theme_dark_onSurfaceVariant, + outline = md_theme_dark_outline, + inverseOnSurface = md_theme_dark_inverseOnSurface, + inverseSurface = md_theme_dark_inverseSurface, + inversePrimary = md_theme_dark_inversePrimary, + surfaceTint = md_theme_dark_surfaceTint, + outlineVariant = md_theme_dark_outlineVariant, + scrim = md_theme_dark_scrim, + surfaceContainerLowest = md_theme_dark_surfaceContainerLowest, + surfaceContainerLow = md_theme_dark_surfaceContainerLow, + surfaceContainer = md_theme_dark_surfaceContainer, + surfaceContainerHigh = md_theme_dark_surfaceContainerHigh, + surfaceContainerHighest = md_theme_dark_surfaceContainerHighest, + surfaceDim = md_theme_dark_surfaceDim, + surfaceBright = md_theme_dark_surfaceBright, + primaryFixed = md_theme_primaryFixed, + primaryFixedDim = md_theme_primaryFixedDim, + onPrimaryFixed = md_theme_onPrimaryFixed, + onPrimaryFixedVariant = md_theme_onPrimaryFixedVariant, + secondaryFixed = md_theme_secondaryFixed, + secondaryFixedDim = md_theme_secondaryFixedDim, + onSecondaryFixed = md_theme_onSecondaryFixed, + onSecondaryFixedVariant = md_theme_onSecondaryFixedVariant, + tertiaryFixed = md_theme_tertiaryFixed, + tertiaryFixedDim = md_theme_tertiaryFixedDim, + onTertiaryFixed = md_theme_onTertiaryFixed, + onTertiaryFixedVariant = md_theme_onTertiaryFixedVariant +) +``` + +### Generating Custom Color Schemes + +Use Material Theme Builder to generate custom schemes: + +1. Visit [Material Theme Builder](https://m3.material.io/theme-builder) +2. Select your brand color +3. Export as Compose (Kotlin) +4. Replace the color values in `Color.kt` + +### Using Colors in Composables + +Always use semantic color roles from `MaterialTheme.colorScheme`: + +```kotlin +@Composable +fun ProfileCard(user: User) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = user.name, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = user.email, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} +``` + +## Color Pairing Rules + +Every M3 color role has an `on*` partner that is contrast-tuned for it. Mixing partners - `onPrimary` over `surface`, `onSurface` over `primaryContainer` - silently breaks WCAG, dark mode, dynamic color, and user contrast all at once. The rule is mechanical: **pick a container role, then use its `on*` for everything drawn on top.** + +### The pairing table + +| Container / fill role | Content / `on*` role | Typical use | +|------------------------------------|----------------------------------------------------------------------------|---------------------------------------| +| `primary` | `onPrimary` | Filled button, FAB | +| `primaryContainer` | `onPrimaryContainer` | Selected chip, hero card | +| `secondary` / `secondaryContainer` | `onSecondary` / `onSecondaryContainer` | Tonal button, filter chip | +| `tertiary` / `tertiaryContainer` | `onTertiary` / `onTertiaryContainer` | Badge, complementary surface | +| `error` / `errorContainer` | `onError` / `onErrorContainer` | Destructive action, error banner | +| `surface` / `surfaceContainer*` | `onSurface` (titles), `onSurfaceVariant` (secondary text, icons, dividers) | Most app content | +| `inverseSurface` | `inverseOnSurface` | Snackbar, tooltip | +| `*Fixed` / `*FixedDim` | `on*Fixed` / `on*FixedVariant` | Cross-mode media controls (see below) | + +### Compose: pair containers and content explicitly + +```kotlin +@Composable +fun PairedSurfaces() { + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + shape = MaterialTheme.shapes.medium, + ) { + Column(Modifier.padding(16.dp)) { + Text("Hero card", style = MaterialTheme.typography.titleMedium) + Text( + text = "Auto-inherits onPrimaryContainer via LocalContentColor", + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} +``` + +Setting `Surface(contentColor = ...)` updates `LocalContentColor` so `Text`, `Icon`, and `IconButton` inside pick the right partner automatically - that's the idiomatic way to enforce pairing without naming colors at every `Text` call. + +### Title vs supporting text on surfaces + +On any `surface` / `surfaceContainer*` role, use **two** content roles, not one: + +- `onSurface` for primary text (titles, body copy that must read). +- `onSurfaceVariant` for secondary text, icons, dividers, placeholders, helper text. + +`onSurfaceVariant` is intentionally lower-contrast - using it for body copy fails WCAG; using `onSurface` for every label flattens the visual hierarchy. + +### `*Fixed` / `*FixedDim`: keep tone constant across modes + +`primaryFixed` / `primaryFixedDim` keep the **same tone** in light and dark themes - useful when a surface (album art controls, an embedded media widget) must visually match across modes. Pair them with `onPrimaryFixed` (titles) and `onPrimaryFixedVariant` (supporting text), the same way `surface` pairs with `onSurface` / `onSurfaceVariant`. + +### Cross-references + +- These pairs are also enforced by `Card`, `Button`, `Chip`, `NavigationBar` etc. via `*Defaults.colors(...)` - see `references/compose-patterns.md`. +- Anti-patterns for breaking pairing live in [Theme routing → Forbidden](#forbidden). + +## `outline` vs `outlineVariant` + +M3 has two outline roles, and they are not interchangeable. Picking the wrong one is the difference between a focusable, accessible boundary and a decorative hairline that disappears for low-vision users. + +| Role | Contrast | Use for | +|------------------|-----------------------------------------|------------------------------------------------------------------------------------------------------------| +| `outline` | High - meets non-text contrast (3:1) | Interactive borders: outlined button/text field/chip, focus indicators, important dividers between regions | +| `outlineVariant` | Low - decorative, **does not** meet 3:1 | Subtle dividers between items in a list, decorative separators, disabled-state borders | + +Rule of thumb: if a sighted user is supposed to **act on** the bordered thing, use `outline`. If the line is purely visual rhythm inside a single region, use `outlineVariant`. + +### Compose: pick the role that matches the job + +```kotlin +@Composable +fun OutlineDemo() { + OutlinedTextField( + value = "", + onValueChange = {}, + label = { Text("Email") }, + ) + + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + ) + + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Text( + text = "Region boundary the user can tab to", + modifier = Modifier + .padding(16.dp) + .focusable(), + ) + } +} +``` + +`OutlinedTextField` already pulls `outline` (and `outlineVariant` for its disabled state) internally - that's the model to follow when you write your own outlined components: take `outline` for the resting interactive border, `outlineVariant` for disabled/decorative. + +### Cross-references + +- Anti-pattern lives in [Theme routing → Forbidden](#forbidden). +- Custom outlined components: see `references/compose-patterns.md` → component patterns. + +## Surface Container Hierarchy + +M3 expresses depth through **container tone**, not shadows. Pick the surface role that matches the component's job, not its visual weight. Nest by stepping **one level up** at each layer (`surface` → `surfaceContainerLow` → `surfaceContainer` → ...) so depth reads cleanly under any contrast or theme. + +### Which level for what + +| Container role | Use for | +|---------------------------|--------------------------------------------------------------------------| +| `surface` | Default screen background | +| `surfaceContainerLowest` | Component on a **busy** background that should recede (rare) | +| `surfaceContainerLow` | Cards laid out in flow on a `surface` background | +| `surfaceContainer` | Persistent containers (navigation bar, side rail, bottom sheet at rest) | +| `surfaceContainerHigh` | Menus, scrolled top app bar, sheets while dragging | +| `surfaceContainerHighest` | Filled cards, deepest nested container (chip on a card on a sheet) | +| `surfaceDim` | Hero/empty-state surface that should always read as the dimmest area | +| `surfaceBright` | Hero/empty-state surface that should always read as the brightest area | + +### Compose nesting example + +```kotlin +@Composable +fun NestedSurfacesDemo() { + Surface(color = MaterialTheme.colorScheme.surface) { + Column(modifier = Modifier.padding(16.dp)) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Card on surface", + color = MaterialTheme.colorScheme.onSurface, + ) + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surfaceContainerHighest, + modifier = Modifier.padding(top = 12.dp) + ) { + Text( + text = "Chip nested inside the card", + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + ) + } + } + } + } + } +} +``` + +The nested chip sits at `surfaceContainerHighest` so it stays distinguishable from the card (`surfaceContainerLow`) and the page (`surface`) regardless of light/dark, dynamic color, or user contrast. + +### Avoid `surfaceVariant` for new containers + +`surfaceVariant` predates the container hierarchy. Keep it only for **legacy** screens or for tinted decorative surfaces (e.g. inactive switch tracks). For any new container, pick a `surfaceContainer*` level instead. + +## Tonal Elevation vs Shadows + +In M3, depth is communicated through **container tone first**. Reach for a shadow only when a component must visually float over content the surface tone can't separate from (a FAB over a photo, a sheet over a busy feed). Stacking shadows for ordinary depth produces the cluttered, MD2-style look M3 was designed to retire. + +### Map elevation level to surface role + +| Elevation level | Tonal role | Components at rest | +|-----------------|-----------------------------|--------------------------------------------------------------------------| +| 0 | `surface` | Most resting components, top app bar (flat), filled/outlined/text button | +| 1 | `surfaceContainerLow` | Elevated card, banner, modal bottom sheet | +| 2 | `surfaceContainer` | Navigation bar, scrolled top app bar, menus, toolbar | +| 3 | `surfaceContainerHigh` | FAB, dialogs, search bar, date/time pickers | +| 4–5 | `surfaceContainerHighest` | Hover/focus increase only - never a resting state | + +Setting `Surface(tonalElevation = 3.dp)` blends `surfaceTint` into `surface` to approximate level 3. **Use the explicit `surfaceContainer*` role** instead — clearer mapping, survives dynamic color and user contrast, and matches what M3 components do internally. + +### Compose: prefer container role, add shadow only when needed + +```kotlin +@Composable +fun ElevationDemo() { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + Text( + text = "Menu surface - tone alone communicates level 2", + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(16.dp), + ) + } + + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + shadowElevation = 6.dp, + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = "Compose", + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(16.dp), + ) + } +} +``` + +The menu uses tone only. The FAB adds `shadowElevation` because it floats over arbitrary content - exactly the case where a shadow is justified. + +### Hover/focus, not resting + +Levels 4 and 5 are **interaction** levels. Bump elevation by **one step** on hover/focus (e.g. FAB level 3 → 4 on hover) and return to rest on release. Never ship a component at rest above level 3. + +### Cross-references + +- M3 Expressive components consume tonal/elevation tokens through `MaterialExpressiveTheme` and `MotionScheme.expressive()` - see [Material 3 Expressive](#material-3-expressive). +- Animation/feel of elevation transitions belongs in `references/compose-patterns.md` → "Animation". + +## Dynamic Color (Material You) + +Dynamic color extracts colors from the user's wallpaper (API 31+). + +### Enabling Dynamic Color + +```kotlin +@Composable +fun AppTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + // Dynamic color is available on API 31+ (Android 12+) + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) { + dynamicDarkColorScheme(context) + } else { + dynamicLightColorScheme(context) + } + } + // Fallback to static color schemes + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = AppTypography, + shapes = AppShapes, + content = content + ) +} +``` + +### User Preference for Dynamic Color + +Allow users to toggle dynamic colors: + +```kotlin +// core/ui/theme/ThemePreference.kt +enum class ThemePreference { + LIGHT, + DARK, + SYSTEM +} + +data class ThemeConfig( + val themePreference: ThemePreference = ThemePreference.SYSTEM, + val useDynamicColor: Boolean = true +) +``` + +### Conditional Dynamic Color Support + +```kotlin +@Composable +fun AppTheme( + themeConfig: ThemeConfig, + content: @Composable () -> Unit +) { + val isDarkTheme = when (themeConfig.themePreference) { + ThemePreference.LIGHT -> false + ThemePreference.DARK -> true + ThemePreference.SYSTEM -> isSystemInDarkTheme() + } + + val supportsDynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + val useDynamicColor = themeConfig.useDynamicColor && supportsDynamicColor + + val colorScheme = when { + useDynamicColor -> { + val context = LocalContext.current + if (isDarkTheme) { + dynamicDarkColorScheme(context) + } else { + dynamicLightColorScheme(context) + } + } + isDarkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = AppTypography, + shapes = AppShapes, + content = content + ) +} +``` + +## User Contrast Preference (Android 14+) + +Android 14 (API 34) added a system-wide **Contrast** slider in *Settings → Accessibility → Color and motion*. Users pick Standard / Medium / High, and the OS exposes the result via `UiModeManager.getContrast()` returning a `Float`: + +| `getContrast()` value | Setting | Use the scheme variant | +|-----------------------|----------|------------------------------------------------| +| `0.0f` | Standard | Default `LightColorScheme` / `DarkColorScheme` | +| `0.5f` | Medium | Medium-contrast variant | +| `1.0f` | High | High-contrast variant | + +Honoring the OS contrast choice is a low-cost M3 accessibility win: read `getContrast()` and select the matching scheme variant. + +### Generate the contrast scheme variants + +Use [Material Theme Builder](https://m3.material.io/theme-builder) → **Export** → it ships six schemes: `Light`, `LightMediumContrast`, `LightHighContrast`, `Dark`, `DarkMediumContrast`, `DarkHighContrast`. Drop them into `Color.kt` next to the existing pair. + +### Compose helper: read contrast reactively + +`UiModeManager.getContrast()` is API 34+ only, and the value can change while the app is foregrounded (user toggles the slider). Listen to `UiModeManager.ContrastChangeListener` and surface it through state. + +```kotlin +import android.app.UiModeManager +import android.content.Context +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.getSystemService + +enum class ContrastLevel { Standard, Medium, High } + +@Composable +fun rememberContrastLevel(): ContrastLevel { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + return ContrastLevel.Standard + } + val context = LocalContext.current + val uiModeManager = remember(context) { context.getSystemService()!! } + var level by remember { mutableStateOf(uiModeManager.contrastLevel()) } + + DisposableEffect(uiModeManager) { + val executor = ContextCompat.getMainExecutor(context) + val listener = UiModeManager.ContrastChangeListener { level = uiModeManager.contrastLevel() } + uiModeManager.addContrastChangeListener(executor, listener) + onDispose { uiModeManager.removeContrastChangeListener(listener) } + } + return level +} + +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +private fun UiModeManager.contrastLevel(): ContrastLevel = when { + contrast >= 0.75f -> ContrastLevel.High + contrast >= 0.25f -> ContrastLevel.Medium + else -> ContrastLevel.Standard +} +``` + +The bucket boundaries (`0.25` / `0.75`) are intentional - the API is documented to return `0.0` / `0.5` / `1.0` today, but bucketing leaves room for future intermediate values without breaking the picker. + +### Plug into `AppTheme` + +Slot the contrast pick into the same `colorScheme` decision tree from [Conditional Dynamic Color Support](#conditional-dynamic-color-support) - pick the static variant that matches `(isDark, contrast)`. With **dynamic color**, `dynamicLightColorScheme(context)` / `dynamicDarkColorScheme(context)` already honor the user contrast on API 34+, so leave them alone. + +```kotlin +@Composable +fun AppTheme( + themeConfig: ThemeConfig, + content: @Composable () -> Unit, +) { + val isDarkTheme = when (themeConfig.themePreference) { + ThemePreference.LIGHT -> false + ThemePreference.DARK -> true + ThemePreference.SYSTEM -> isSystemInDarkTheme() + } + val dynamicColorActive = + themeConfig.useDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + val contrast = rememberContrastLevel() + + val colorScheme = when { + dynamicColorActive -> { + val context = LocalContext.current + if (isDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + isDarkTheme -> when (contrast) { + ContrastLevel.High -> DarkHighContrastColorScheme + ContrastLevel.Medium -> DarkMediumContrastColorScheme + ContrastLevel.Standard -> DarkColorScheme + } + else -> when (contrast) { + ContrastLevel.High -> LightHighContrastColorScheme + ContrastLevel.Medium -> LightMediumContrastColorScheme + ContrastLevel.Standard -> LightColorScheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = AppTypography, + shapes = AppShapes, + content = content, + ) +} +``` + +### What this does **not** replace + +User contrast scales the **color scheme**. It is not a substitute for: + +- WCAG contrast checks on hard-coded brand colors (still required - see `references/android-accessibility.md`). +- A larger-text / display-density preference (those are separate system settings). +- Honoring user font scale (`fontScale` in `Configuration`) - that affects typography, not color. + +### Testing + +`adb shell settings put secure contrast_level 0.5` toggles the system value without going through the slider. Pair with the existing dark-mode preview pattern: + +```kotlin +@Preview(name = "Light · Standard") +@Preview(name = "Light · Medium", group = "contrast") +@Preview(name = "Light · High", group = "contrast") +@Preview(name = "Dark · Standard", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "Dark · High", uiMode = Configuration.UI_MODE_NIGHT_YES, group = "contrast") +``` + +Previews can't read the live `UiModeManager`, so wrap your composable in a small test theme that takes `ContrastLevel` as a parameter. + +## Typography Scales + +Material 3 provides predefined typography scales. + +### Default Typography + +```kotlin +// core/ui/theme/Type.kt +package com.example.core.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Custom font family (optional) +val Roboto = FontFamily( + Font(R.font.roboto_regular, FontWeight.Normal), + Font(R.font.roboto_medium, FontWeight.Medium), + Font(R.font.roboto_bold, FontWeight.Bold) +) + +val AppTypography = Typography( + // Display styles - largest text + displayLarge = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.Normal, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp + ), + displayMedium = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.Normal, + fontSize = 45.sp, + lineHeight = 52.sp, + letterSpacing = 0.sp + ), + displaySmall = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.Normal, + fontSize = 36.sp, + lineHeight = 44.sp, + letterSpacing = 0.sp + ), + + // Headline styles + headlineLarge = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.Normal, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = 0.sp + ), + headlineMedium = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.Normal, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp + ), + headlineSmall = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.Normal, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp + ), + + // Title styles + titleLarge = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + titleMedium = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp + ), + titleSmall = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + + // Body styles + bodyLarge = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ), + bodyMedium = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp + ), + bodySmall = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp + ), + + // Label styles + labelLarge = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + labelMedium = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ), + labelSmall = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) +) +``` + +### Using Typography + +```kotlin +@Composable +fun ArticleScreen(article: Article) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + // Use display for hero text + Text( + text = article.title, + style = MaterialTheme.typography.displayMedium, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Use body for content + Text( + text = article.content, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Use label for metadata + Text( + text = "By ${article.author}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} +``` + +### Android 16 (API 36) Font Changes + +Android 16 deprecates and disables the `elegantTextHeight` `TextView` attribute. The "UI fonts" controlled by this API are discontinued. Apps targeting API 36 must ensure layouts render correctly with the default readable font rendering for Arabic, Lao, Myanmar, Tamil, Gujarati, Kannada, Malayalam, Odia, Telugu, and Thai scripts. + +**What changed:** +- In Android 15 (API 35), `elegantTextHeight` defaulted to `true`, replacing compact fonts with more readable ones +- In Android 16 (API 36), the attribute is ignored entirely -- readable fonts are always used +- Any layouts that relied on `elegantTextHeight = false` for compact rendering must be adapted + +**Action required:** +- Remove any `elegantTextHeight` attribute usage from XML layouts and styles +- Do **not** set `elegantTextHeight` programmatically -- it has no effect on API 36 +- Test text rendering for the affected scripts listed above and adjust layout spacing if needed +- Use Compose `Text` composables with `MaterialTheme.typography` scales (no `elegantTextHeight` concept in Compose) + +### Adding Custom Fonts + +Add fonts to `res/font/`: + +``` +res/ + font/ + roboto_regular.ttf + roboto_medium.ttf + roboto_bold.ttf +``` + +## Shape Theming + +Material 3 uses four shape scales: Extra Small, Small, Medium, Large, Extra Large. + +### Default Shapes + +```kotlin +// core/ui/theme/Shape.kt +package com.example.core.ui.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +val AppShapes = Shapes( + extraSmall = RoundedCornerShape(4.dp), + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(12.dp), + large = RoundedCornerShape(16.dp), + extraLarge = RoundedCornerShape(28.dp) +) +``` + +### Custom Shape Scales + +For more rounded or angular designs: + +```kotlin +// Rounded design +val RoundedShapes = Shapes( + extraSmall = RoundedCornerShape(8.dp), + small = RoundedCornerShape(12.dp), + medium = RoundedCornerShape(16.dp), + large = RoundedCornerShape(20.dp), + extraLarge = RoundedCornerShape(32.dp) +) + +// Angular design +val AngularShapes = Shapes( + extraSmall = RoundedCornerShape(2.dp), + small = RoundedCornerShape(4.dp), + medium = RoundedCornerShape(6.dp), + large = RoundedCornerShape(8.dp), + extraLarge = RoundedCornerShape(12.dp) +) +``` + +### Using Shapes + +Components automatically use the correct shape from the theme: + +```kotlin +@Composable +fun ProductCard(product: Product) { + // Card automatically uses medium shape + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + // Image with large shape + AsyncImage( + model = product.imageUrl, + contentDescription = product.name, + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .clip(MaterialTheme.shapes.large) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = product.name, + style = MaterialTheme.typography.titleLarge + ) + + // Button uses large shape by default + Button( + onClick = { /* Add to cart */ }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Add to Cart") + } + } + } +} +``` + +## Material 3 Expressive + +Material 3 Expressive is the 2025+ refresh of Material 3. It adds a **motion scheme**, expressive color tokens, and shape/typography updates. In Compose it is exposed via `MaterialExpressiveTheme` and sibling color-scheme builders. + +### Status + +- API lives in `androidx.compose.material3` and is marked `@ExperimentalMaterial3ExpressiveApi`. +- Shipped in `androidx.compose.material3:material3:1.5.0-alpha16` and later. +- The pinned catalog version (`material3` in `assets/libs.versions.toml.template`) gates availability. On stable 1.4.x, Expressive APIs are **not** available; keep `MaterialTheme` as the canonical entry point. +- Do not mix `MaterialTheme` and `MaterialExpressiveTheme` in the same tree. Pick one per Activity/Composable root. + +### Opt-in + +Enable the opt-in at the file or module level: + +```kotlin +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) +``` + +Module-wide (library modules, not `:app` per `references/kotlin-patterns.md`): + +```kotlin +// build.gradle.kts (module) +kotlin { + compilerOptions { + optIn.add("androidx.compose.material3.ExperimentalMaterial3ExpressiveApi") + } +} +``` + +### Wiring + +```kotlin +@Composable +fun AppTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val context = LocalContext.current + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + darkTheme -> expressiveDarkColorScheme() + else -> expressiveLightColorScheme() + } + + MaterialExpressiveTheme( + colorScheme = colorScheme, + motionScheme = MotionScheme.expressive(), + typography = Typography, + shapes = Shapes, + content = content + ) +} +``` + +### What changes vs `MaterialTheme` + +| Slot | `MaterialTheme` | `MaterialExpressiveTheme` | +|------------|--------------------------------------------|----------------------------------------------------------------| +| Color | `lightColorScheme()` / `darkColorScheme()` | `expressiveLightColorScheme()` / `expressiveDarkColorScheme()` | +| Motion | Not a theme slot; per-component defaults | `MotionScheme.expressive()` / `MotionScheme.standard()` | +| Typography | `Typography` | Same `Typography` slot; expressive defaults differ | +| Shapes | `Shapes` | Same `Shapes` slot; expressive defaults use larger corners | + +The `motionScheme` slot is the distinguishing feature: it centralises duration and easing tokens that Material 3 components (FAB, dialogs, switches, segmented buttons) pick up automatically. + +### When to adopt + +- Adopt when the catalog's `material3` is pinned to a version that ships the API as stable, or when the product explicitly signs off on using an experimental API. +- Until then, use stable `MaterialTheme` plus the token overrides shown above. Migration path: swap `MaterialTheme(...)` for `MaterialExpressiveTheme(...)` and add a `MotionScheme` argument. + +## Dark/Light Mode Switching + +### System Default + +```kotlin +@Composable +fun AppTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme + + MaterialTheme( + colorScheme = colorScheme, + typography = AppTypography, + shapes = AppShapes, + content = content + ) +} +``` + +### User-Controlled Theme + +```kotlin +@Composable +fun AppTheme( + themePreference: ThemePreference, + useDynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val isDarkTheme = when (themePreference) { + ThemePreference.LIGHT -> false + ThemePreference.DARK -> true + ThemePreference.SYSTEM -> isSystemInDarkTheme() + } + + val colorScheme = when { + useDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (isDarkTheme) { + dynamicDarkColorScheme(context) + } else { + dynamicLightColorScheme(context) + } + } + isDarkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = AppTypography, + shapes = AppShapes, + content = content + ) +} +``` + +### Theme Switcher UI + +```kotlin +@Composable +fun ThemeSettingsScreen( + currentTheme: ThemePreference, + useDynamicColor: Boolean, + onThemeChange: (ThemePreference) -> Unit, + onDynamicColorChange: (Boolean) -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + Text( + text = "Theme Settings", + style = MaterialTheme.typography.headlineMedium + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Theme selection + Text( + text = "Appearance", + style = MaterialTheme.typography.titleMedium + ) + + Spacer(modifier = Modifier.height(8.dp)) + + ThemePreference.entries.forEach { preference -> + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = currentTheme == preference, + onClick = { onThemeChange(preference) }, + role = Role.RadioButton + ) + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = currentTheme == preference, + onClick = null + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = when (preference) { + ThemePreference.LIGHT -> "Light" + ThemePreference.DARK -> "Dark" + ThemePreference.SYSTEM -> "System default" + }, + style = MaterialTheme.typography.bodyLarge + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Dynamic color toggle (API 31+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Row( + modifier = Modifier + .fillMaxWidth() + .toggleable( + value = useDynamicColor, + onValueChange = onDynamicColorChange, + role = Role.Switch + ) + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Dynamic colors", + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = "Use colors from your wallpaper", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = useDynamicColor, + onCheckedChange = null + ) + } + } + } +} +``` + +## Theme Preferences + +### DataStore Implementation + +```kotlin +// core/data/preferences/ThemePreferencesDataSource.kt +package com.example.core.data.preferences + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.example.core.ui.theme.ThemeConfig +import com.example.core.ui.theme.ThemePreference +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.dataStore: DataStore by preferencesDataStore( + name = "theme_preferences" +) + +@Singleton +class ThemePreferencesDataSource @Inject constructor( + @ApplicationContext private val context: Context +) { + private object PreferencesKeys { + val THEME_PREFERENCE = stringPreferencesKey("theme_preference") + val USE_DYNAMIC_COLOR = booleanPreferencesKey("use_dynamic_color") + } + + val themeConfig: Flow = context.dataStore.data.map { preferences -> + val themePreference = preferences[PreferencesKeys.THEME_PREFERENCE]?.let { + ThemePreference.valueOf(it) + } ?: ThemePreference.SYSTEM + + val useDynamicColor = preferences[PreferencesKeys.USE_DYNAMIC_COLOR] ?: true + + ThemeConfig( + themePreference = themePreference, + useDynamicColor = useDynamicColor + ) + } + + suspend fun setThemePreference(preference: ThemePreference) { + context.dataStore.edit { preferences -> + preferences[PreferencesKeys.THEME_PREFERENCE] = preference.name + } + } + + suspend fun setUseDynamicColor(useDynamicColor: Boolean) { + context.dataStore.edit { preferences -> + preferences[PreferencesKeys.USE_DYNAMIC_COLOR] = useDynamicColor + } + } +} +``` + +### Repository + +```kotlin +// core/domain/ThemeRepository.kt +package com.example.core.domain + +import com.example.core.ui.theme.ThemeConfig +import com.example.core.ui.theme.ThemePreference +import kotlinx.coroutines.flow.Flow + +interface ThemeRepository { + val themeConfig: Flow + suspend fun setThemePreference(preference: ThemePreference) + suspend fun setUseDynamicColor(useDynamicColor: Boolean) +} +``` + +```kotlin +// core/data/ThemeRepositoryImpl.kt +package com.example.core.data + +import com.example.core.data.preferences.ThemePreferencesDataSource +import com.example.core.domain.ThemeRepository +import com.example.core.ui.theme.ThemeConfig +import com.example.core.ui.theme.ThemePreference +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ThemeRepositoryImpl @Inject constructor( + private val themePreferencesDataSource: ThemePreferencesDataSource +) : ThemeRepository { + + override val themeConfig: Flow = + themePreferencesDataSource.themeConfig + + override suspend fun setThemePreference(preference: ThemePreference) { + themePreferencesDataSource.setThemePreference(preference) + } + + override suspend fun setUseDynamicColor(useDynamicColor: Boolean) { + themePreferencesDataSource.setUseDynamicColor(useDynamicColor) + } +} +``` + +### Hilt Module + +```kotlin +// core/di/ThemeModule.kt +package com.example.core.di + +import com.example.core.data.ThemeRepositoryImpl +import com.example.core.domain.ThemeRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +abstract class ThemeModule { + @Binds + abstract fun bindThemeRepository( + impl: ThemeRepositoryImpl + ): ThemeRepository +} +``` + +## Custom Theme Attributes + +### Extended Color Scheme + +Add custom colors beyond Material 3's default palette: + +```kotlin +// core/ui/theme/ExtendedColors.kt +package com.example.core.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +@Immutable +data class ExtendedColors( + val success: Color, + val onSuccess: Color, + val warning: Color, + val onWarning: Color, + val info: Color, + val onInfo: Color +) + +val LightExtendedColors = ExtendedColors( + success = Color(0xFF4CAF50), + onSuccess = Color(0xFFFFFFFF), + warning = Color(0xFFFFC107), + onWarning = Color(0xFF000000), + info = Color(0xFF2196F3), + onInfo = Color(0xFFFFFFFF) +) + +val DarkExtendedColors = ExtendedColors( + success = Color(0xFF81C784), + onSuccess = Color(0xFF000000), + warning = Color(0xFFFFD54F), + onWarning = Color(0xFF000000), + info = Color(0xFF64B5F6), + onInfo = Color(0xFF000000) +) + +val LocalExtendedColors = staticCompositionLocalOf { LightExtendedColors } +``` + +### Providing Extended Colors + +```kotlin +// core/ui/theme/Theme.kt +@Composable +fun AppTheme( + themeConfig: ThemeConfig, + content: @Composable () -> Unit +) { + val isDarkTheme = when (themeConfig.themePreference) { + ThemePreference.LIGHT -> false + ThemePreference.DARK -> true + ThemePreference.SYSTEM -> isSystemInDarkTheme() + } + + val colorScheme = when { + themeConfig.useDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (isDarkTheme) { + dynamicDarkColorScheme(context) + } else { + dynamicLightColorScheme(context) + } + } + isDarkTheme -> DarkColorScheme + else -> LightColorScheme + } + + val extendedColors = if (isDarkTheme) { + DarkExtendedColors + } else { + LightExtendedColors + } + + CompositionLocalProvider(LocalExtendedColors provides extendedColors) { + MaterialTheme( + colorScheme = colorScheme, + typography = AppTypography, + shapes = AppShapes, + content = content + ) + } +} + +// Extension for easy access +object AppTheme { + val extendedColors: ExtendedColors + @Composable + get() = LocalExtendedColors.current +} +``` + +### Using Extended Colors + +```kotlin +@Composable +fun StatusBadge(status: String) { + val (backgroundColor, contentColor) = when (status) { + "success" -> AppTheme.extendedColors.success to AppTheme.extendedColors.onSuccess + "warning" -> AppTheme.extendedColors.warning to AppTheme.extendedColors.onWarning + "info" -> AppTheme.extendedColors.info to AppTheme.extendedColors.onInfo + else -> MaterialTheme.colorScheme.surface to MaterialTheme.colorScheme.onSurface + } + + Surface( + color = backgroundColor, + shape = MaterialTheme.shapes.small, + modifier = Modifier.padding(4.dp) + ) { + Text( + text = status.uppercase(), + color = contentColor, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } +} +``` + +### Brand Color Harmonization + +Hard-coded brand colors (`success`, `warning`, `info`, an "always-red" notification dot, a partner logo tint) clash visibly when [dynamic color](#dynamic-color-material-you) repaints the rest of the app from the user's wallpaper. M3 ships a fix: `MaterialColors.harmonize(...)` shifts a custom color's **hue** toward `colorScheme.primary` while preserving its **chroma and tone**, so `success` still reads as green and `warning` as yellow without fighting the wallpaper palette. + +Add the dependency once in `build.gradle.kts`: + +```kotlin +implementation("com.google.android.material:material:1.12.0") +``` + +#### Harmonize once when the scheme is built + +`harmonize` is a pure color-math call. Run it where you build `ExtendedColors` so every consumer sees harmonized values automatically - never call it inside `Composable`s that recompose on every frame. + +```kotlin +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import com.google.android.material.color.MaterialColors + +private fun Color.harmonizeWith(primary: Color): Color = + Color(MaterialColors.harmonize(this.toArgb(), primary.toArgb())) + +@Composable +fun rememberHarmonizedExtendedColors( + base: ExtendedColors, + primary: Color = MaterialTheme.colorScheme.primary, +): ExtendedColors = remember(base, primary) { + base.copy( + success = base.success.harmonizeWith(primary), + warning = base.warning.harmonizeWith(primary), + info = base.info.harmonizeWith(primary), + ) +} +``` + +`on*` partners stay as-is - they're chosen for contrast against the harmonized fill, and the fill's hue shift is too small to flip which on-color you need. + +#### Plug into `AppTheme` + +Replace the static `extendedColors` lookup in `AppTheme` with the harmonized version, but **only when dynamic color is actually active**. With the static `LightColorScheme` / `DarkColorScheme` there's nothing to harmonize against, so skip the cost. + +```kotlin +@Composable +fun AppTheme( + themeConfig: ThemeConfig, + content: @Composable () -> Unit, +) { + val isDarkTheme = when (themeConfig.themePreference) { + ThemePreference.LIGHT -> false + ThemePreference.DARK -> true + ThemePreference.SYSTEM -> isSystemInDarkTheme() + } + + val dynamicColorActive = + themeConfig.useDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + + val colorScheme = when { + dynamicColorActive -> { + val context = LocalContext.current + if (isDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + isDarkTheme -> DarkColorScheme + else -> LightColorScheme + } + + val baseExtended = if (isDarkTheme) DarkExtendedColors else LightExtendedColors + val extendedColors = if (dynamicColorActive) { + rememberHarmonizedExtendedColors(baseExtended, colorScheme.primary) + } else { + baseExtended + } + + CompositionLocalProvider(LocalExtendedColors provides extendedColors) { + MaterialTheme( + colorScheme = colorScheme, + typography = AppTypography, + shapes = AppShapes, + content = content, + ) + } +} +``` + +#### When to harmonize, when not to + +- **Harmonize**: brand accents (`success`, `warning`, `info`), partner-tinted illustrations, a custom `notification` color, third-party SDK accent overrides. +- **Do not harmonize**: `error` (already part of `colorScheme`, must stay unmistakably red), pure neutrals (white, black, grays), brand colors with **legal/identity constraints** where the exact hex matters (logos, regulated marks). +- For static-only apps (no dynamic color anywhere), there's nothing to harmonize against - skip it entirely and keep the original brand values. + +## Scoped Themes + +Sometimes a single screen needs its own slice of theming - a settings *Danger Zone* whose primary is `error`, an "on-media" toolbar that sits over a dark hero image, an embedded brand surface inside a partner section. The right tool is **a nested `MaterialTheme`** that derives from the outer one with `colorScheme.copy(...)`. This keeps dynamic color, user contrast, and dark mode intact for the rest of the app while overriding only the roles you actually care about. + +### Rule: `copy()` from the outer scheme, never rebuild + +Always start from `MaterialTheme.colorScheme` and `.copy(...)` the roles you want to change. Re-instantiating `lightColorScheme(...)` from scratch silently throws away the user's dynamic palette and contrast pick. + +```kotlin +@Composable +fun ErrorScope(content: @Composable () -> Unit) { + val outer = MaterialTheme.colorScheme + MaterialTheme( + colorScheme = outer.copy( + primary = outer.error, + onPrimary = outer.onError, + primaryContainer = outer.errorContainer, + onPrimaryContainer = outer.onErrorContainer, + ), + typography = MaterialTheme.typography, + shapes = MaterialTheme.shapes, + content = content, + ) +} + +@Composable +fun DangerZone() { + ErrorScope { + Button(onClick = { /* ... */ }) { + Text("Delete account") + } + } +} +``` + +The `Button` reads `colorScheme.primary` like any other M3 component; inside `ErrorScope` that role maps to `error`. No custom `ButtonColors`, no per-component overrides, no leakage outside the `ErrorScope` block. + +### Common scoped-theme patterns + +- **Destructive scope**: map `primary` → `error`, `primaryContainer` → `errorContainer` (above). Wrap the dangerous CTA only, not the whole screen. +- **On-media scope**: a toolbar over a photo can switch to `inverseSurface` / `inverseOnSurface` so icons stay legible regardless of the underlying image. +- **Brand-tinted section**: an embedded partner area can swap `primary` and `secondary` for the partner's harmonized brand color, keeping the surface hierarchy intact. +- **Forced light/dark**: a media player that should always render dark UI can pass a frozen dark `colorScheme` to its subtree without touching the rest of the app. + +### Don'ts + +- **Don't scope shapes or typography** unless the design genuinely diverges — those tokens rebuild the visual identity beyond palette swaps and rarely belong in a scope. +- **Don't scope to override a single component**. If only one `Button` needs a different fill, pass `ButtonDefaults.buttonColors(containerColor = ...)`. Reach for a scoped theme when **multiple** components in a subtree need the override. +- **Don't nest more than one level deep.** Two layered scopes mean the inner subtree should read the outer color roles instead of adding another theme layer. +- **Don't introduce a scoped theme for accessibility-critical actions** without re-checking contrast - the new pairing must still satisfy WCAG. Run the same checks as for the base scheme (see `references/android-accessibility.md`). + +## Architecture Integration + +### ViewModel Integration + +```kotlin +// feature/settings/presentation/SettingsViewModel.kt +package com.example.feature.settings.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.core.domain.ThemeRepository +import com.example.core.ui.theme.ThemeConfig +import com.example.core.ui.theme.ThemePreference +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val themeRepository: ThemeRepository +) : ViewModel() { + + val themeConfig: StateFlow = themeRepository.themeConfig + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = ThemeConfig() + ) + + fun setThemePreference(preference: ThemePreference) { + viewModelScope.launch { + themeRepository.setThemePreference(preference) + } + } + + fun setUseDynamicColor(useDynamicColor: Boolean) { + viewModelScope.launch { + themeRepository.setUseDynamicColor(useDynamicColor) + } + } +} +``` + +### App-Level Theme State + +Edge-to-edge is mandatory on API 36. Use `Scaffold` for proper inset handling. + +```kotlin +// app/MainActivity.kt +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + @Inject lateinit var themeRepository: ThemeRepository + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + enableEdgeToEdge() + + setContent { + val themeConfig by themeRepository.themeConfig + .collectAsStateWithLifecycle(initialValue = ThemeConfig()) + + AppTheme(themeConfig = themeConfig) { + Scaffold( + modifier = Modifier.fillMaxSize() + ) { innerPadding -> + MainNavigation( + modifier = Modifier.padding(innerPadding) + ) + } + } + } + } +} +``` + +## Testing + +### Fake Theme Repository + +```kotlin +// core/testing/FakeThemeRepository.kt +package com.example.core.testing + +import com.example.core.domain.ThemeRepository +import com.example.core.ui.theme.ThemeConfig +import com.example.core.ui.theme.ThemePreference +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class FakeThemeRepository : ThemeRepository { + private val _themeConfig = MutableStateFlow(ThemeConfig()) + override val themeConfig: Flow = _themeConfig.asStateFlow() + + override suspend fun setThemePreference(preference: ThemePreference) { + _themeConfig.value = _themeConfig.value.copy(themePreference = preference) + } + + override suspend fun setUseDynamicColor(useDynamicColor: Boolean) { + _themeConfig.value = _themeConfig.value.copy(useDynamicColor = useDynamicColor) + } + + fun setThemeConfig(config: ThemeConfig) { + _themeConfig.value = config + } +} +``` + +### Testing Theme Changes + +```kotlin +// feature/settings/presentation/SettingsViewModelTest.kt +@Test +fun `setThemePreference updates theme config`() = runTest { + val fakeThemeRepository = FakeThemeRepository() + val viewModel = SettingsViewModel(fakeThemeRepository) + + viewModel.setThemePreference(ThemePreference.DARK) + advanceUntilIdle() + + val themeConfig = viewModel.themeConfig.value + assertEquals(ThemePreference.DARK, themeConfig.themePreference) +} + +@Test +fun `setUseDynamicColor updates theme config`() = runTest { + val fakeThemeRepository = FakeThemeRepository() + val viewModel = SettingsViewModel(fakeThemeRepository) + + viewModel.setUseDynamicColor(false) + advanceUntilIdle() + + val themeConfig = viewModel.themeConfig.value + assertEquals(false, themeConfig.useDynamicColor) +} +``` + +### UI Testing with Theme + +```kotlin +@Test +fun `theme settings screen shows correct theme selection`() { + composeTestRule.setContent { + AppTheme { + ThemeSettingsScreen( + currentTheme = ThemePreference.DARK, + useDynamicColor = true, + onThemeChange = {}, + onDynamicColorChange = {} + ) + } + } + + composeTestRule + .onNodeWithText("Dark") + .assertIsSelected() +} +``` + +## Layout Spacing and Component Dimensions + +Use an **8 dp grid** for spacing (4 dp only for fine tuning). Map tokens to `Modifier.padding` / `Spacer` consistently across features. + +| Token | Value | Typical use | +|-------|-------|--------------------------------------| +| xs | 4 dp | Icon padding, tight gaps | +| sm | 8 dp | Inline spacing, dense lists | +| md | 16 dp | Default screen and card padding | +| lg | 24 dp | Section separation | +| xl | 32 dp | Large gaps between groups | +| xxl | 48 dp | Screen edge margins on compact width | + +**Common component heights** (Material 3; combine with minimum **48 dp** touch targets in `references/android-accessibility.md`) + +| Component | Height / size | Notes | +|-------------------|-------------------------------|-----------------------------------| +| Standard button | 40 dp height, min width 64 dp | Touch target still at least 48 dp | +| FAB | 56 x 56 dp | Mini FAB 40 dp when spec allows | +| Text field | 56 dp tall, min width ~280 dp | Includes label area | +| Top app bar | 64 dp | | +| Bottom navigation | 80 dp | | +| Navigation rail | 80 dp width | | + +## Reserved Resource Names + +Avoid **Android-reserved or overly generic** names for colors, drawables, and IDs. They can cause merge errors, shadow system resources, or confusing generated `R` fields. + +| Category | Avoid as a resource name | +|----------------|-------------------------------------------------------------------------------------------------------------| +| Colors | `background`, `foreground`, `transparent`, `white`, `black` (prefer `app_background`, `icon_primary`, etc.) | +| Drawables | `icon`, `logo`, `image`, `drawable` | +| Generic | `view`, `text`, `button`, `layout`, `container` | +| Meta | `id`, `name`, `type`, `style`, `theme`, `color` as bare names | +| Namespace-like | `app`, `android`, `content`, `data`, `action` | + +In Kotlin, prefer descriptive names (`screenBackground`) over labels that read like framework APIs. + +## Visual Style by App Category + +Match **density, color, motion, and typography** to product category. Use the table below to pick defaults; deviate only with explicit product sign-off. + +| App category | Visual direction | Interaction notes | +|------------------------|---------------------------------------------------------|----------------------------------------------------------| +| Utility / tools | Minimal, neutral palette, clear hierarchy | Fast paths, little ornament | +| Finance / business | Conservative colors, structured layout | Confirm destructive actions | +| Health / wellness | Soft palette, generous whitespace | Encouraging, not alarming copy | +| Kids (younger) | Bright colors, large type (18 sp+), very rounded shapes | Large targets (56 dp+), avoid text-only critical actions | +| Kids (older) | Vibrant but readable | Gamification ok; keep navigation obvious | +| Social / entertainment | Brand-forward, media-rich | Gestures ok if alternatives exist | +| Productivity | High contrast options, dense modes | Keyboard and focus friendly | +| E-commerce | Clear CTAs, scannable prices | Fast cart and checkout paths | +| Games | Theme-driven | Follow platform sign-in and parent gates where required | + +**Style mismatches to avoid:** playful palette on finance, dense dashboards on meditation apps, tiny touch targets on kids flows, clownish UI on enterprise tools. + +## Theme routing + +### Required + +1. **Use semantic color roles** from `MaterialTheme.colorScheme` (never hardcoded colors) +2. **Support both light and dark themes** with proper contrast +3. **Test accessibility** - ensure WCAG color contrast ratios (see `references/android-accessibility.md`) +4. **Use typography scales** from `MaterialTheme.typography` (avoid custom text sizes) +5. **Provide dynamic color support** on API 31+ for Material You +6. **Allow user theme preference** (Light/Dark/System) +7. **Use shape scales** from `MaterialTheme.shapes` for consistency +8. **Persist theme preferences** using DataStore (not SharedPreferences) +9. **Handle edge-to-edge** UI properly with `enableEdgeToEdge()` and `Scaffold` (mandatory on API 36) +10. **Test on both themes** to ensure content is readable +11. **Do not use `elegantTextHeight` attribute** - it is deprecated and ignored on API 36 + +### Forbidden + +1. **Never hardcode colors** in composables (`Color(0xFFFF0000)`) +2. **Never hardcode text sizes** or font weights +3. **Never assume light theme** - always support dark theme +4. **Never use deprecated theming APIs** (MaterialTheme from material package) +5. **Never ignore system theme** unless user explicitly overrides +6. **Never forget to test color contrast** in dark mode +7. **Never use `isSystemInDarkTheme()` in ViewModels** (only in composables) +8. **Never create custom color attributes** without considering light/dark variants +9. **Never use `Color.Unspecified`** - always provide fallback colors +10. **Never test theme in emulator only** - test on real devices with different wallpapers +11. **Never mix color pairs** - `onPrimary` only goes on `primary`, `onPrimaryContainer` only on `primaryContainer` (see [Color Pairing Rules](#color-pairing-rules)); pulling content roles off their partner silently breaks contrast under dark mode, dynamic color, and user contrast +12. **Never use `onSurface` for everything on a surface** - secondary text, icons, dividers, and helper text belong on `onSurfaceVariant`; using `onSurface` everywhere flattens the visual hierarchy +13. **Never use `outlineVariant` for interactive borders** - it's a decorative role and does not meet 3:1 non-text contrast; outlined buttons, text fields, focus indicators, and region boundaries the user can tab to must use `outline` (see [`outline` vs `outlineVariant`](#outline-vs-outlinevariant)) + +### Color Naming Convention + +Use semantic names, not visual descriptions: + +```kotlin +// WRONG: visual descriptor names (`lightBlue`) instead of semantic roles +val lightBlue = Color(0xFF2196F3) +val darkBlue = Color(0xFF1976D2) + +// CORRECT: semantic role names (`primary`, `primaryVariant`) +val primary = Color(0xFF2196F3) +val primaryVariant = Color(0xFF1976D2) +``` + +### Theme Transitions + +For smooth theme transitions, use `animateColorAsState`: + +```kotlin +@Composable +fun ThemedCard() { + val backgroundColor by animateColorAsState( + targetValue = MaterialTheme.colorScheme.surface, + label = "background" + ) + + Card( + colors = CardDefaults.cardColors( + containerColor = backgroundColor + ) + ) { + // Content + } +} +``` + +### Preview with Themes + +Always preview both light and dark themes: + +```kotlin +@Preview(name = "Light", showBackground = true) +@Preview(name = "Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ProfileCardPreview() { + AppTheme { + ProfileCard( + user = User(name = "Jane Doe", email = "jane@example.com") + ) + } +} +``` + +### Material Theme Builder + +Use [Material Theme Builder](https://m3.material.io/theme-builder) to: +1. Generate custom color schemes from brand colors +2. Preview components with your theme +3. Export Compose code directly +4. Ensure WCAG contrast compliance + +### Dynamic Color Considerations + +- Dynamic colors work best for **content-focused apps** (news, social, productivity) +- Consider **disabling by default** for **brand-focused apps** (banking, enterprise) +- Always provide **static fallback** for API < 31 +- Test with **various wallpapers** - light, dark, colorful, monochrome + +## References + +- [Material Design 3](https://m3.material.io/) +- [Material Theme Builder](https://m3.material.io/theme-builder) +- [Compose Material3 API](https://developer.android.com/reference/kotlin/androidx/compose/material3/package-summary) +- [Dynamic Color](https://m3.material.io/styles/color/dynamic-color/overview) +- [Typography](https://m3.material.io/styles/typography/overview) +- [Shape](https://m3.material.io/styles/shape/overview) +- [Color System](https://m3.material.io/styles/color/system/overview) +- [Accessibility Color Contrast](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html) diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/references/architecture.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/architecture.md new file mode 100644 index 000000000..b33676321 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/architecture.md @@ -0,0 +1,938 @@ +# Architecture + +Layer rules for Android apps using Jetpack Compose, Navigation 3, Hilt, and a multi-module setup. All Kotlin code must align with `references/kotlin-patterns.md`. Offline-first sync, conflict resolution, and retry policy: `references/android-data-sync.md`. + +## Table of Contents +1. [Architecture Overview](#architecture-overview) +2. [Architecture Principles](#architecture-principles) +3. [Cross-cutting anti-patterns (quick reference)](#cross-cutting-anti-patterns-quick-reference) +4. [Data Layer](#data-layer) +5. [Domain Layer](#domain-layer) +6. [Presentation Layer](#presentation-layer) +7. [UI Layer](#ui-layer) +8. [Navigation](#navigation) +9. [Complete Architecture Flow](#complete-architecture-flow) + +## Architecture Overview + +Four-layer architecture with strict module separation and unidirectional data flow: + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ FEATURE MODULES (feature/*) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ Presentation Layer │ │ +│ │ ┌──────────────┐ ┌──────────────────────────┐ │ │ +│ │ │ Screen │◄───│ ViewModel │ │ │ +│ │ │ (Compose) │ │ (StateFlow) │ │ │ +│ │ └──────────────┘ └────────────┬─────────────┘ │ │ +│ │ │ │ │ +│ └───────────────────────────────────┼─────────────────────────────┘ │ +│ │ Uses │ +├───────────────────────────────────────┼─────────────────────────────────┤ +│ CORE/DOMAIN Module │ │ +│ ┌───────────────────────────────────▼──────────────────────┐ │ +│ │ Domain Layer │ │ +│ │ ┌────────────────────────────────────────────────────┐ │ │ +│ │ │ Use Cases │ │ │ +│ │ │ (combine/transform logic) │ │ │ +│ │ └───────────────────────┬────────────────────────────┘ │ │ +│ │ ┌───────────────────────▼────────────────────────────┐ │ │ +│ │ │ Repository Interfaces │ │ │ +│ │ │ (contracts for data layer) │ │ │ +│ │ └───────────────────────┬────────────────────────────┘ │ │ +│ │ ┌───────────────────────▼────────────────────────────┐ │ │ +│ │ │ Domain Models │ │ │ +│ │ │ (business entities) │ │ │ +│ │ └────────────────────────────────────────────────────┘ │ │ +│ └────────────────────────────────────┬─────────────────────┘ │ +│ │ Implements │ +├────────────────────────────────────────┼────────────────────────────────┤ +│ CORE/DATA Module │ │ +│ ┌────────────────────────────────────▼──────────────────────┐ │ +│ │ Data Layer │ │ +│ │ ┌────────────────────────────────────────────────────┐ │ │ +│ │ │ Repository Implementations │ │ │ +│ │ │ (offline-first, single source of truth) │ │ │ +│ │ └─────────┬─────────────────────┬────────────────────┘ │ │ +│ │ │ │ │ │ +│ │ ┌─────────▼─────────┐ ┌────────▼──────────────┐ │ │ +│ │ │ Local DataSource │ │ Remote DataSource │ │ │ +│ │ │ (Room 3 + DAO) │ │ (Retrofit) │ │ │ +│ │ └─────────┬─────────┘ └───────────────────────┘ │ │ +│ │ │ │ │ +│ │ ┌─────────▼──────────────────────────────────────┐ │ │ +│ │ │ Data Models │ │ │ +│ │ │ (Entity, DTO, Response objects) │ │ │ +│ │ └────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────┘ │ +├─────────────────────────────────────────────────────────────────────────┤ +│ CORE/UI Module (shared UI resources) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ UI Layer │ │ +│ │ ┌─────────────────────────────────────────────────────────┐ │ │ +│ │ │ Shared UI Components │ │ │ +│ │ │ (Buttons, Cards, Dialogs, etc.) │ │ │ +│ │ └─────────────────────────────────────────────────────────┘ │ │ +│ │ ┌─────────────────────────────────────────────────────────┐ │ │ +│ │ │ Themes & Design System │ │ │ +│ │ │ (Colors, Typography, Shapes) │ │ │ +│ │ └─────────────────────────────────────────────────────────┘ │ │ +│ │ ┌─────────────────────────────────────────────────────────┐ │ │ +│ │ │ Base ViewModels / State Management │ │ │ +│ │ │ (BaseViewModel, UiState, etc.) │ │ │ +│ │ └─────────────────────────────────────────────────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +## Architecture Principles + +1. **Offline-first**: Local database is source of truth, sync with remote +2. **Unidirectional data flow**: Events flow down, data flows up +3. **Reactive streams**: Use Kotlin Flow/StateFlow for all data exposure +4. **Modular by feature**: Each feature is self-contained with clear boundaries +5. **Testable by design**: Use interfaces and fakes for testing; MockK only for framework classes in app module (see `references/testing.md`) +6. **Layer separation**: Strict separation between Presentation, Domain, Data, and UI layers +7. **Dependency direction**: Features depend on Core modules, not on other features +8. **Navigation coordination**: App module coordinates navigation between features +9. **Pattern fit**: Choose patterns that match Android constraints and the module boundaries (see `references/design-patterns.md`) + +## Cross-cutting anti-patterns (quick reference) + +Domain-specific pitfalls (navigation, Room 3, Paging, etc.) live in their topic references. **Scope:** layering and state shape. Deeper guidance on recomposition and stability: `references/android-performance.md` and `references/compose-patterns.md`. + +| Anti-pattern | Failure mode | Use instead | +|------------------------------------------------------------------------------------|--------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Business logic in composables | Competing sources of truth, hard to test, work reruns during composition | ViewModel / domain services / repositories; composables map state → UI | +| Oversized "god" ViewModel | Hard to own and change safely | One ViewModel per screen or one coherent flow | +| Unstable `UiState` (mutable collections, non-stable lambdas in state) | Weakens Compose skipping, extra recompositions | Immutable `data class` / persistent collections; stable types | +| Duplicated derived fields (`total`, `formattedTotal`, `hasTotal` all stored) | Fields drift out of sync | One canonical value; derive the rest (computed properties or at the UI edge) | +| Parent reads too much `StateFlow` / state | Recomposition fans out to the whole subtree | Pass only the slices each child needs | +| One-shot UI as sticky state (`showSnackbarOnce = true`) | Can replay after config change or rotation | One-shot commands: `Channel` + `receiveAsFlow()` (or a carefully configured `SharedFlow`) collected in the Route; see `references/compose-patterns.md` and `references/coroutines-patterns.md` | +| `StateFlow` updates when nothing changed | Wasted recompositions | Compare before `update` / avoid redundant `copy` | +| ViewModel performs platform work (navigation, share sheet, analytics side effects) | Couples logic to Android, harder to test | Emit one-shot events or callbacks; handle in Route / Activity edge | +| Display strings fully formatted in ViewModel | Locale/layout rigidity, duplicated presentation | Keep canonical values; format with resources at the Compose boundary (`references/android-i18n.md`) | +| Lazy list keys missing or index-based | Wrong item state after reorder/delete | Stable domain id as key (`references/compose-patterns.md`) | +| Many trivial composables (thin wrappers around one `Text` / `Spacer`) | Noise, weak boundaries | Extract only meaningful, reused UI blocks | +| Fully qualified package names inline | Hard to read | Top-level imports; `import … as …` when names clash (`references/kotlin-patterns.md`) | + +## Module Structure + +See the full module layout and naming conventions in `references/modularization.md`. + +## Data Layer + +### Principles +- **Offline-first**: Local database is the source of truth +- **Repository pattern**: Single public API for data access +- **Reactive streams**: All data exposed as `Flow` or `StateFlow` +- **Model mapping**: Separate Entity (database), DTO (network), and Domain models + +### Repository Pattern + +The repository interface is defined in `core/domain` (see [Repository Interface Pattern](#repository-interface-pattern) in Domain Layer section). + +```kotlin +// core/data - Repository implementation +internal class AuthRepositoryImpl @Inject constructor( + private val localDataSource: AuthLocalDataSource, + private val remoteDataSource: AuthRemoteDataSource, + private val authMapper: AuthMapper, + private val crashReporter: CrashReporter +) : AuthRepository { + + override suspend fun login(email: String, password: String): Result = + try { + val response = remoteDataSource.login(email, password) + localDataSource.saveAuthToken(response.token) + localDataSource.saveUser(authMapper.toEntity(response.user)) + Result.success(response.token) + } catch (e: IOException) { + crashReporter.recordException(e, mapOf("action" to "login")) + Result.failure(AuthError.NetworkError("No internet connection", e)) + } catch (e: HttpException) { + when (e.code()) { + 401 -> Result.failure(AuthError.InvalidCredentials("Invalid email or password")) + else -> { + crashReporter.recordException(e, mapOf("action" to "login", "code" to e.code())) + Result.failure(AuthError.ServerError("Server error", e)) + } + } + } catch (e: Exception) { + crashReporter.recordException(e, mapOf("action" to "login")) + Result.failure(AuthError.UnknownError("Unexpected error", e)) + } + + override suspend fun register(user: User): Result = + try { + remoteDataSource.register(authMapper.toNetwork(user)) + Result.success(Unit) + } catch (e: IOException) { + crashReporter.recordException(e, mapOf("action" to "register")) + Result.failure(AuthError.NetworkError("No internet connection", e)) + } catch (e: HttpException) { + when (e.code()) { + 409 -> Result.failure(AuthError.UserAlreadyExists("Email already registered")) + else -> { + crashReporter.recordException(e, mapOf("action" to "register", "code" to e.code())) + Result.failure(AuthError.ServerError("Server error", e)) + } + } + } catch (e: Exception) { + crashReporter.recordException(e, mapOf("action" to "register")) + Result.failure(AuthError.UnknownError("Unexpected error", e)) + } + + override suspend fun resetPassword(email: String): Result = + remoteDataSource.resetPassword(email) + + override fun observeAuthState(): Flow = + localDataSource.observeAuthToken() + .map { token -> + if (token != null) { + val user = authMapper.toDomain(localDataSource.getUser()) + AuthState.Authenticated(user) + } else { + AuthState.Unauthenticated + } + } + + override fun observeAuthEvents(): Flow = + localDataSource.observeAuthEvents() + + override suspend fun refreshSession(): Result = + remoteDataSource.refreshSession() +} +``` + +### Data Sources + +| Type | Module | Implementation | Purpose | +|-------------|----------------|-----------------|-------------------------------------| +| Local | core/database | Room 3 DAO | Persistent storage, source of truth | +| Remote | core/network | Retrofit API | Network data fetching | +| Preferences | core/datastore | Proto DataStore | User settings, simple key-value | + +### DataStore (Preferences & Typed) + +**Storage routing:** +- **Room 3:** Relational data, SQL queries (`WHERE` / `JOIN`), indexes, unbounded or **large** collections (order-of **~100+ entries** signals Room over DataStore), partial updates, referential integrity. +- **DataStore:** Small preference blobs: simple key-value pairs, typed settings objects, feature flags. Does **not** support partial updates, ad hoc queries, or relational integrity-use Room 3 when you need those. +- **Files:** Large media, blobs. +- **MultiProcessDataStoreFactory:** Only if accessing data across multiple processes-and then **every** reader/writer for that file must use the multi-process path (see Critical Rules). + +**Critical Rules:** +1. **Never** create more than one instance of `DataStore` for a given file in the same process (it will throw `IllegalStateException`). +2. The generic type `T` in `DataStore` **must be immutable**. Mutating it breaks consistency. +3. **Never mix access modes for the same file:** If any code path uses `MultiProcessDataStoreFactory`, **all** access to that file must use it. Do not combine it with single-process `PreferenceDataStoreFactory` or the `preferencesDataStore` delegate for the same backing store. + +#### Preferences DataStore +Use for simple key-value pairs without type safety. + +```kotlin +// Define keys +object PrefsKeys { + val THEME_MODE = intPreferencesKey("theme_mode") + val HAS_SEEN_ONBOARDING = booleanPreferencesKey("has_seen_onboarding") +} + +// Read with error handling +fun getThemeMode(dataStore: DataStore): Flow = dataStore.data + .catch { exception -> + if (exception is IOException) emit(emptyPreferences()) else throw exception + } + .map { prefs -> prefs[PrefsKeys.THEME_MODE] ?: ThemeMode.SYSTEM } + +// Write +suspend fun setThemeMode(dataStore: DataStore, mode: Int) { + dataStore.edit { prefs -> + prefs[PrefsKeys.THEME_MODE] = mode + } +} +``` + +#### Typed DataStore (JSON / Proto) +Use for custom classes with type safety. Requires a `Serializer`. + +```kotlin +@Serializable +data class UserSettings( + val themeMode: Int = 0, + val hasSeenOnboarding: Boolean = false +) + +object UserSettingsSerializer : Serializer { + override val defaultValue: UserSettings = UserSettings() + + override suspend fun readFrom(input: InputStream): UserSettings = try { + Json.decodeFromString(input.readBytes().decodeToString()) + } catch (e: SerializationException) { + throw CorruptionException("Unable to read UserSettings", e) + } + + override suspend fun writeTo(t: UserSettings, output: OutputStream) { + output.write(Json.encodeToString(t).encodeToByteArray()) + } +} +``` + +#### SharedPreferences Migration + +**Required:** pass `SharedPreferencesMigration` when replacing legacy `SharedPreferences` keys backed by the same file: + +```kotlin +val dataStore = PreferenceDataStoreFactory.create( + migrations = listOf(SharedPreferencesMigration(context, "legacy_prefs")), + produceFile = { context.preferencesDataStoreFile("settings") } +) +``` + +#### Hilt Setup + +**Scope:** preference and typed `DataStore` wiring only. For **Hilt module rules** (`@Binds` vs `@Provides`, scopes, anti-patterns, Navigation3 + `hiltViewModel`), see [Dependency Injection Setup](#dependency-injection-setup) under **Domain Layer**. + +The `preferencesDataStore(name = ...)` property delegate on `Context` is acceptable only for trivial wiring. For injectable `DataStore` graphs, expose `@Singleton` `@Provides` factories like `DataStoreModule` so tests swap fakes without static `Context`. + +Always provide `DataStore` as a `@Singleton` to guarantee a single instance per file. + +```kotlin +@Module +@InstallIn(SingletonComponent::class) +object DataStoreModule { + + @Provides + @Singleton + fun provideUserSettingsDataStore( + @ApplicationContext context: Context + ): DataStore = DataStoreFactory.create( + serializer = UserSettingsSerializer, + produceFile = { context.dataStoreFile("user_settings.json") }, + corruptionHandler = ReplaceFileCorruptionHandler { UserSettings() } + ) +} +``` + +### Network Layer Setup (core/network) + +#### Retrofit Service Interfaces + +All endpoint functions must be `suspend`. Use `Response` only when you need access to status +codes or error bodies; use the body type directly when 2xx is the only expected success case. + +```kotlin +interface AuthApiService { + + @POST("auth/login") + suspend fun login(@Body request: LoginRequest): LoginResponse + + @POST("auth/register") + suspend fun register(@Body request: RegisterRequest): Response + + @GET("users/{id}") + suspend fun getUser(@Path("id") userId: String): NetworkUser + + @GET("users/search") + suspend fun searchUsers( + @Query("q") query: String, + @Query("page") page: Int = 1, + @Query("limit") limit: Int = 20 + ): PaginatedResponse + + @Multipart + @PUT("users/{id}/avatar") + suspend fun uploadAvatar( + @Path("id") userId: String, + @Part avatar: MultipartBody.Part + ): NetworkUser +} +``` + +#### Network DTOs and nullable JSON fields + +Wire formats do not match your ideal domain model: fields can be **missing**, **null**, or **renamed** across API versions. Types used only for JSON (network DTOs) should reflect that. + +- Use **nullable** properties for fields the server can omit or null out; map to non-null domain types in the repository or mapper after defaults are defined. +- Keep `Json { ignoreUnknownKeys = true }` (see `NetworkModule`) so new server fields do not crash deserialization. +- Avoid fake non-nulls such as `String = ""` for "missing" JSON keys unless you have a strict, documented contract. Empty string is ambiguous versus "present but empty". + +```kotlin +@Serializable +data class NetworkUser( + val id: String? = null, + val displayName: String? = null, + val avatarUrl: String? = null, +) +``` + +Use `@SerialName("json_name")` when wire names differ from Kotlin properties. Gson users apply the same idea with `@SerializedName`. + +#### Hilt NetworkModule + +```kotlin +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + @Provides + @Singleton + fun provideJson(): Json = Json { + ignoreUnknownKeys = true + coerceInputValues = true + isLenient = true + } + + @Provides + @Singleton + fun provideOkHttpClient( + authInterceptor: AuthInterceptor + ): OkHttpClient = OkHttpClient.Builder() + .addInterceptor(authInterceptor) + .addInterceptor( + HttpLoggingInterceptor().apply { + level = if (BuildConfig.DEBUG) { + HttpLoggingInterceptor.Level.BODY + } else { + HttpLoggingInterceptor.Level.NONE + } + } + ) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + @Provides + @Singleton + fun provideRetrofit(okHttpClient: OkHttpClient, json: Json): Retrofit = + Retrofit.Builder() + .baseUrl(BuildConfig.BASE_URL) + .client(okHttpClient) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + + @Provides + @Singleton + fun provideAuthApiService(retrofit: Retrofit): AuthApiService = + retrofit.create(AuthApiService::class.java) +} +``` + +#### Authentication Interceptor + +Inject auth tokens via an `Interceptor` instead of adding `@Header` parameters to every endpoint: + +```kotlin +class AuthInterceptor @Inject constructor( + private val tokenProvider: TokenProvider +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val token = tokenProvider.getToken() + ?: return chain.proceed(chain.request()) + + val request = chain.request().newBuilder() + .header("Authorization", "Bearer $token") + .build() + return chain.proceed(request) + } +} +``` + +Network exceptions (`HttpException`, `IOException`) are caught and mapped to domain error types +in the repository layer - see [Repository Pattern](#repository-pattern) above and +[Domain-Specific Error Types](#domain-specific-error-types) below. + +### Model Mapping Strategy + +Use mappers when transformations add business logic, not for simple 1:1 field mappings. + +```kotlin +// core/data/mapping/AuthMapper.kt +class AuthMapper @Inject constructor( + private val dateFormatter: DateFormatter +) { + + // Entity → Domain (with date formatting) + fun toDomain(entity: UserEntity?): User = User( + id = entity?.id.orEmpty(), + email = entity?.email.orEmpty(), + name = entity?.name.orEmpty(), + profileImage = entity?.profileImage, + memberSince = entity?.createdAt?.let { dateFormatter.formatMemberSince(it) } ?: "Unknown", + lastActive = entity?.lastActiveAt?.let { dateFormatter.formatRelativeTime(it) } ?: "Never" + ) + + // Network → Entity (with timestamp normalization) + fun toEntity(user: NetworkUser): UserEntity = UserEntity( + id = user.id, + email = user.email.lowercase().trim(), // Normalize email + name = user.name.trim(), + profileImage = user.profileImage, + createdAt = user.createdAt, + lastActiveAt = Clock.System.now().toEpochMilliseconds() // Track local access time + ) + + // Domain → Network (for register/update) + fun toNetwork(user: User): NetworkUser = NetworkUser( + id = user.id, + email = user.email, + name = user.name, + profileImage = user.profileImage + ) +} +``` + +### Domain-Specific Error Types + +```kotlin +// core/domain/error/AuthError.kt +sealed class AuthError(message: String, cause: Throwable? = null) : Exception(message, cause) { + class NetworkError(message: String, cause: Throwable? = null) : AuthError(message, cause) + class InvalidCredentials(message: String) : AuthError(message) + class UserAlreadyExists(message: String) : AuthError(message) + class ServerError(message: String, cause: Throwable? = null) : AuthError(message, cause) + class UnknownError(message: String, cause: Throwable? = null) : AuthError(message, cause) +} +``` + +For crash reporting integration, see `references/crashlytics.md`. + +### Data Synchronization + +```kotlin +// core/data/sync/AuthSessionWorker.kt +@HiltWorker +class AuthSessionWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted params: WorkerParameters, + private val authRepository: AuthRepository +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + authRepository.refreshSession().fold( + onSuccess = { Result.success() }, + onFailure = { error -> + when (error) { + is AuthError.NetworkError -> Result.retry() + is AuthError.ServerError -> if (runAttemptCount < 3) Result.retry() else Result.failure() + else -> Result.failure() + } + } + ) + } +} +``` + +## Domain Layer + +### Purpose +- **Pure business logic module** (minimal Android dependencies) +- Encapsulate complex business logic +- Remove duplicate logic from ViewModels +- Combine and transform data from multiple repositories +- **Optional but recommended** for complex applications + +### Module Setup + +Domain modules can be either: +- **Pure JVM/Kotlin modules** (`app.jvm.library`) - No Android dependencies +- **Android library modules** (`app.android.library`) - If you need `@Immutable`/`@Stable` annotations on domain models + +```kotlin +// Option 1: Pure Kotlin module (no @Immutable annotations) +// core/domain/build.gradle.kts +plugins { + alias(libs.plugins.app.jvm.library) + alias(libs.plugins.app.hilt) +} + +// Option 2: Android library (enables @Immutable for domain models) +// core/domain/build.gradle.kts +plugins { + alias(libs.plugins.app.android.library) + alias(libs.plugins.app.hilt) +} + +dependencies { + // Only if using Option 2 and want @Immutable on domain models + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.runtime) // Kotlin-only, no Android deps +} +``` + +`androidx.compose.runtime` is Kotlin-only despite its namespace. Use it from `core/domain` to access `@Immutable` and `@Stable` without pulling in Android dependencies. See [compose-patterns.md](/references/compose-patterns.md#stability-annotations-immutable-vs-stable). + +### Dependency Injection Setup + +Hilt provides **compile-time DI** across features and core modules: `@Module` / `@InstallIn`, with `@HiltAndroidApp` and `@AndroidEntryPoint` entry points in the app module. + +**Constructor injection:** Use `@Inject constructor(...)` on types Hilt builds. Avoid `@Inject lateinit var` on app or domain types (their dependencies are hidden and tests get harder). Platform types may still use field injection where the API requires it. + +**`@Binds` vs `@Provides`:** +- **`@Binds`** - `abstract` method in a module; map an interface to an `@Inject`-constructable implementation (Hilt generates the binding). +- **`@Provides`** - you construct the instance (`OkHttpClient.Builder()`, `DataStoreFactory.create`, third-party SDKs). See **Hilt NetworkModule** and **DataStore** `#### Hilt Setup` in this Data Layer for real `@Provides` examples. + +**Scopes (match real lifetime):** + +| Annotation | Lifetime | Typical use | +|---------------------------|------------------------------------------------|------------------------------------------------------| +| `@Singleton` | Application | Retrofit, `OkHttp`, Room 3, `DataStore`, dispatchers | +| `@ActivityRetainedScoped` | Survives config change until activity finished | Session-like state that must survive rotation | +| `@ViewModelScoped` | Same as hosting `ViewModel` | Feature helpers (validators, calculators) | +| `@ActivityScoped` | Activity instance | Rare in Compose-first apps | +| `@FragmentScoped` | Fragment instance | Rare when using Compose | + +Over-scoping wastes memory; under-scoping duplicates heavy types or breaks singleton expectations. + +**Modules:** Colocate modules with the **feature or layer** they wire (`AuthModule` in a feature, `DatabaseModule` in `core/data`). The app module owns the application graph entry points. + +**Anti-patterns:** + +| Problem | Failure mode | Use instead | +|----------------------------------------------------------|--------------------------------|---------------------------------------------------------------------------| +| `Activity` / `Fragment` in a `ViewModel` | Leaks, lifecycle mismatch | Ids via `SavedStateHandle`, navigation args, repositories | +| Raw `Context` in a `ViewModel` | Same | `@ApplicationContext` in data/repository wiring, not in `ViewModel` | +| `ViewModel` with `@Inject` but no `@HiltViewModel` | Hilt does not own the instance | `@HiltViewModel` + `@Inject constructor` + `hiltViewModel()` in Compose | +| Manual `ViewModel(...)` factories everywhere | Bypasses graph | `hiltViewModel()` or Hilt-assisted factories | +| Feature-only deps parked in `SingletonComponent` preemptively | Wrong lifetime, memory | `ViewModelComponent` / `@ViewModelScoped` when only screens need the type | + +**Navigation arguments and assisted injection:** `SavedStateHandle`, `@AssistedInject`, and `hiltViewModel` factory lambdas with Navigation3 - see `references/android-navigation.md` as the source of truth. + +Official docs: [Hilt Android](https://developer.android.com/training/dependency-injection/hilt-android), [Hilt with Compose](https://developer.android.com/develop/ui/compose/libraries#hilt). + +**Example - repository bindings in `core/data`:** + +```kotlin +// core/data/di/DataModule.kt +@Module +@InstallIn(SingletonComponent::class) +abstract class DataModule { + + @Binds + @Singleton + abstract fun bindAuthRepository( + impl: AuthRepositoryImpl + ): AuthRepository + + @Binds + @Singleton + abstract fun bindUserRepository( + impl: UserRepositoryImpl + ): UserRepository +} +``` + +### Use Case Pattern + +**Use when:** + +1. The operation combines data from multiple repositories. +2. The operation centralizes domain logic reused across features or ViewModels. +3. The logic is too heavy for the `ViewModel` but does not belong in a repository. + +**Forbidden:** pass-through use cases that only wrap a single repository call — call the repository from the `ViewModel` instead. + +```kotlin +// WRONG: Unnecessary use case (simple pass-through) +class LoginUseCase @Inject constructor( + private val authRepository: AuthRepository +) { + suspend operator fun invoke(email: String, password: String): Result = + authRepository.login(email, password) // No added value +} + +// CORRECT: Valuable use case (combines multiple repositories) +class GetUserProfileWithStatsUseCase @Inject constructor( + private val userRepository: UserRepository, + private val activityRepository: ActivityRepository, + private val achievementRepository: AchievementRepository +) { + operator fun invoke(userId: String): Flow = combine( + userRepository.observeUser(userId), + activityRepository.observeActivityCount(userId), + achievementRepository.observeAchievements(userId) + ) { user, activityCount, achievements -> + UserProfileWithStats( + user = user, + totalActivities = activityCount, + achievements = achievements, + completionRate = calculateCompletionRate(activityCount, achievements) + ) + } + + private fun calculateCompletionRate(activities: Int, achievements: List): Float { + if (activities == 0) return 0f + val completed = achievements.count { it.isCompleted } + return (completed.toFloat() / activities) * 100 + } +} + +// CORRECT: Valuable use case (complex validation logic) +class ValidateRegistrationUseCase @Inject constructor() { + operator fun invoke(email: String, password: String, confirmPassword: String): Result { + if (!email.matches(EMAIL_REGEX)) { + return Result.failure(ValidationError.InvalidEmail) + } + if (password.length < 8) { + return Result.failure(ValidationError.PasswordTooShort) + } + if (password != confirmPassword) { + return Result.failure(ValidationError.PasswordMismatch) + } + if (!password.matches(PASSWORD_STRENGTH_REGEX)) { + return Result.failure(ValidationError.PasswordTooWeak) + } + return Result.success(Unit) + } + + companion object { + private val EMAIL_REGEX = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$") + private val PASSWORD_STRENGTH_REGEX = Regex("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$") + } +} +``` + +### Repository Interface Pattern + +```kotlin +// core/domain/repository/AuthRepository.kt +// CORRECT: @Stable: Interface contract guarantees observable changes +@Stable +interface AuthRepository { + suspend fun login(email: String, password: String): Result + suspend fun register(user: User): Result + suspend fun resetPassword(email: String): Result + fun observeAuthState(): Flow // Flow emissions are observable + fun observeAuthEvents(): Flow + suspend fun refreshSession(): Result +} +``` + +### Domain Models + +Domain models should be annotated with `@Immutable` for Compose stability. Use `@Immutable` for deeply immutable types (all `val` properties), and `@Stable` for mutable types with observable changes (see `references/compose-patterns.md` for detailed guidance). + +```kotlin +// core/domain/model/ + +// CORRECT: @Immutable: Deeply immutable data +@Immutable +data class User( + val id: String, + val email: String, + val name: String, + val profileImage: String? = null +) + +@Immutable +data class AuthToken( + val value: String, + val user: User +) + +@Immutable +sealed class AuthState { + data object Loading : AuthState() + data object Unauthenticated : AuthState() + data class Authenticated(val user: User) : AuthState() + data class Error(val message: String) : AuthState() +} + +@Immutable +sealed class AuthEvent { + data class SessionRefreshed(val timestamp: Instant) : AuthEvent() + data class SessionExpired(val reason: String) : AuthEvent() + data class Error(val message: String, val retryable: Boolean) : AuthEvent() +} + +sealed class ValidationError : Exception() { + data object InvalidEmail : ValidationError() + data object PasswordTooShort : ValidationError() + data object PasswordTooWeak : ValidationError() + data object PasswordMismatch : ValidationError() +} +``` + +## Presentation Layer + +### Location: Feature modules (`feature/*`) + +### Components +- **Screen**: Main composable UI +- **ViewModel**: State holder and event processor +- **UiState**: Sealed interface representing all possible UI states +- **Actions**: Sealed class representing user interactions + +### ViewModel placement + +Default: one ViewModel per screen, scoped to the back stack entry via `NavEntryDecorator`. Reusable composables stay stateless and hoist state to the parent screen. + +Escape hatch: scope a ViewModel to a composable's call site with `rememberViewModelStoreOwner()` only for genuinely complex, single-instance, non-screen composables (media-player widget, multi-step wizard, in-page editor) - see [android-navigation.md → Scoping to a non-screen composable](/references/android-navigation.md#scoping-to-a-non-screen-composable). + +Forbidden: a ViewModel inside `LazyColumn` items, list cells, or any reusable component. + +### UiState, Actions, and ViewModel Patterns + +Use `references/compose-patterns.md` for the detailed UiState, Action, and ViewModel +examples. Keep presentation logic in feature modules and keep UI composables stateless +where possible. + +## UI Layer + +### Location: `core/ui` (shared) and feature modules (specific) + +### Screen Composition and Shared UI Components + +Compose screen and component patterns live in `references/compose-patterns.md` to keep +UI guidance centralized. + +## Navigation + +For Navigation3 architecture, type-safe routing, state management, adaptive navigation +(`NavigationSuiteScaffold`), and migration guidance, see `references/android-navigation.md`. + +## Complete Architecture Flow + +### User Interaction Flow (UI → Data): +``` +User Action → Screen → ViewModel → UseCase → Repository → Data Source + (Event) (UI) (State) (Business) (Access) (Persistence) + ↓ ↓ ↓ ↓ ↓ ↓ + Click → Composable → Process → Transform → Retrieve → Local/Remote +``` + +### Data Response Flow (Data → UI): +``` +Data Source → Repository → UseCase → ViewModel → UiState → Screen + (Change) (Update) (Combine) (Update) (State) (Render) + ↓ ↓ ↓ ↓ ↓ ↓ + DB Update → Map Data → Business Logic → StateFlow → Observe → Recomposition +``` + +### Navigation Flow (Feature Coordination): +``` +User Action → Screen → Navigator Interface → App Module → Navigation3 + (Navigate) (Call) (Contract) (Implementation) (Routing) + ↓ ↓ ↓ ↓ ↓ + Tap Link → Call navigate() → Interface → App Navigator → NavController → Destination +``` + +## Combined Complete Flow Diagram: + +``` +┌────────────────────────────────────────────────────────────────────────────────────┐ +│ USER INTERACTION FLOW │ +│ │ +│ User Action (Event) │ +│ ↓ │ +│ ┌────────────────────────────────────────────────────────────────────────────┐ │ +│ │ PRESENTATION LAYER │ │ +│ │ ┌─────────────┐ ┌─────────────────────────┐ ┌──────────────────────┐ │ │ +│ │ │ Screen │ │ ViewModel │ │ Navigator │ │ │ +│ │ │ (Composable)│ │ (StateFlow) │ │ (Interface) │ │ │ +│ │ └─────┬───────┘ └───────────┬─────────────┘ └──────────┬───────────┘ │ │ +│ │ │ │ │ │ │ +│ └────────┼──────────────────────┼───────────────────────────┼────────────────┘ │ +│ │ onAction() │ updateUiState() │ navigate() │ +├───────────┼──────────────────────┼───────────────────────────┼─────────────────────┤ +│ │ │ │ │ +│ ┌────────▼──────────┐ ┌─────────▼──────────┐ ┌─────────▼──────────────┐ │ +│ │ DOMAIN LAYER │ │ DATA LAYER │ │ NAVIGATION │ │ +│ │ ┌─────────────┐ │ │ ┌──────────────┐ │ │ (App Module) │ │ +│ │ │ UseCase │ │ │ │ Repository │ │ │ ┌──────────────────┐ │ │ +│ │ │ (Business) │ │ │ │ (Data Access)│ │ │ │ App Navigator │ │ │ +│ │ └──────┬──────┘ │ │ └──────┬───────┘ │ │ │ (Implementation) │ │ │ +│ │ │ invoke()│ │ │ getData()│ │ └──────────┬───────┘ │ │ +│ └─────────┼─────────┘ └─────────┼──────────┘ └─────────────┼──────────┘ │ +│ │ │ │ │ +│ ┌─────────▼─────────────────────▼───────────────────────────────▼──────────────┐ │ +│ │ DATA SOURCES / NAVIGATION ENGINE │ │ +│ │ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────────────────────┐ │ │ +│ │ │ Local Storage │ │ Remote API │ │ Navigation3 │ │ │ +│ │ │ (Room 3) │ │ (Retrofit) │ │ (NavController) │ │ │ +│ │ └─────────────────┘ └─────────────────┘ └──────────────────────────────┘ │ │ +│ └──────────────────────────────────────────────────────────────────────────────┘ │ +│ │ +├────────────────────────────────────────────────────────────────────────────────────┤ +│ DATA RESPONSE FLOW │ +│ │ +│ ┌────────────────────────────────────────────────────────────────────────────┐ │ +│ │ REACTIVE DATA STREAM │ │ +│ │ │ │ +│ │ Data Change (Local/Remote) → Repository Flow → UseCase Transform → │ │ +│ │ ViewModel StateFlow → Screen Observation → UI Recomposition │ │ +│ │ │ │ +│ └────────────────────────────────────────────────────────────────────────────┘ │ +│ │ +├────────────────────────────────────────────────────────────────────────────────────┤ +│ NAVIGATION RESPONSE FLOW │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────────────┐ │ +│ │ ADAPTIVE UI RENDERING │ │ +│ │ │ │ +│ │ Navigation3 Route → Feature Graph → Screen Destination → │ │ +│ │ NavigationSuiteScaffold → Adaptive Layout → UI Render │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────────────────────┘ │ +└────────────────────────────────────────────────────────────────────────────────────┘ +``` + +## Key Flow Rules: + +### 1. **Unidirectional Event Flow (DOWN):** +``` +User Action → Screen → ViewModel → UseCase → Repository → Data Source + ↓ ↓ ↓ ↓ ↓ ↓ + Tap/Click → Handle → Process → Business → Data Access → Persist/Request +``` + +### 2. **Unidirectional Data Flow (UP):** +``` +Data Source → Repository → UseCase → ViewModel → UiState → Screen → UI + ↓ ↓ ↓ ↓ ↓ ↓ ↓ + DB/Network → Map → Combine → Update → Observe → Render → Display +``` + +### 3. **Unidirectional Navigation Flow:** +``` +Screen → Navigator Interface → App Module → Navigation3 → Destination Screen + ↓ ↓ ↓ ↓ ↓ +Call navigate() → Contract → Implementation → Routing → Render New UI +``` + +## Concrete Example Flow: Resetting a Password + +### Phase 1: User Interaction (Event Flow DOWN) +``` +1. User taps "Forgot Password?" on the login screen +2. Screen: LoginScreen calls viewModel.onAction(AuthAction.ForgotPasswordClicked) +3. ViewModel: AuthViewModel switches to AuthUiState.ForgotPasswordForm +4. User enters email and taps "Reset Password" +5. ViewModel: Calls ResetPasswordUseCase(email) +6. Repository: AuthRepository.resetPassword(email) +7. Data Source: RemoteAuthDataSource sends reset email +``` + +### Phase 2: Data Response (Data Flow UP) +``` +1. Remote data source returns Result +2. Repository maps response to Result +3. ViewModel updates uiState with isEmailSent or emailError +4. Screen observes uiState, shows confirmation or error +``` + +### Phase 3: Navigation Example (Separate Flow) +``` +1. User taps "Create Account" +2. Screen: Calls authNavigator.navigateToRegister() +3. Navigator Interface: AuthNavigator.navigateToRegister() contract +4. App Module: AppNavigator implementation routes to "auth/register" +5. Navigation3: NavController navigates to register destination +6. Feature Graph: Renders RegisterScreen +7. UI: Shows registration form +``` + +- **Features are independent** (no feature-to-feature dependencies) +- **Navigation is coordinated centrally** (app module) +- **Data flows through defined layers** (UI → Domain → Data) +- **Each concern has clear boundaries** (navigation vs. business logic vs. UI rendering) \ No newline at end of file diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/references/code-quality.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/code-quality.md new file mode 100644 index 000000000..606ba88ce --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/code-quality.md @@ -0,0 +1,314 @@ +# Code Quality (Detekt) + +Detekt is the required static analysis tool. Apply it to every module through the `build-logic` convention plugin so configuration stays identical across modules. + +## Goals +- Single source of truth for rules (`plugins/detekt.yml`) with optional per-module overrides. +- Type-resolution enabled tasks for accurate analysis in Android modules. +- Compose-specific rules via the Compose detekt ruleset plugin. +- Kotlin 2.2.x compatible configuration without legacy `buildscript` usage. + +## Version Catalog +Use `assets/libs.versions.toml.template` as the source of truth for: +- The Detekt plugin version and plugin ID. +- The Compose detekt rules dependency (`compose-rules-detekt`). + +Use `assets/detekt.yml.template` as the baseline rules file; copy it to +`plugins/detekt.yml` and customize it there (modules can optionally provide +a local `detekt.yml` override). + +## Detekt Convention Plugin (Build Logic) + +The `DetektConventionPlugin` is available in `assets/convention/DetektConventionPlugin.kt`. + +Copy it to `build-logic/convention/src/main/kotlin/DetektConventionPlugin.kt` in your project. + +Key features: +- Applies Detekt plugin from version catalog +- Adds Compose rules automatically +- Configures central config file (`config/detekt.yml`) +- Supports module-specific overrides +- Enables type resolution for Android modules +- Generates XML, HTML, and SARIF reports + +### Build Logic Registration + +The Detekt plugin is already registered in the build script available at `assets/convention/build.gradle.kts`. + +When you copy the build script to your project's `build-logic/convention/build.gradle.kts`, the Detekt plugin registration is included: + +```kotlin +register("detekt") { + id = "app.detekt" + implementationClass = "DetektConventionPlugin" +} +``` + +## Apply in Modules + +Apply the convention plugin in every module: +```kotlin +plugins { + alias(libs.plugins.app.detekt) +} +``` + +## Running Detekt + +### Local Development + +Run detekt for all modules: +```bash +./gradlew detekt +``` + +Run for specific module: +```bash +./gradlew :app:detekt +./gradlew :feature-auth:detekt +``` + +Run with type resolution (slower, more accurate): +```bash +./gradlew detektMain +``` + +### Excluding Generated Code + +Add to `plugins/detekt.yml`: +```yaml +build: + excludes: + - '**/build/**' + - '**/generated/**' + - '**/*.kts' + - '**/resources/**' +``` + +## Baselines & CI + +### Detekt baseline routing + +**Use baselines when:** + +- Adopting detekt in an existing project with many violations. +- New violations must fail CI while legacy debt is scheduled. +- Rules roll out gradually behind a baseline. + +**Forbidden:** + +- Greenfield projects — fix findings instead of freezing debt. +- Active refactors that need signal — baselines mask regressions. + +### Creating Per-Module Baselines + +Generate baseline for a specific module: +```bash +./gradlew :app:detektBaseline +``` + +This creates `app/detekt-baseline.xml` which suppresses existing issues in that module only. + +Commit the baseline: +```bash +git add app/detekt-baseline.xml +git commit -m "Add detekt baseline for app module" +``` + +### CI Integration + +**GitHub Actions example:** + +```yaml +name: Code Quality + +on: + pull_request: + branches: [ main ] + push: + branches: [ main ] + +jobs: + detekt: + name: Detekt Check + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Run Detekt + run: ./gradlew detekt + + - name: Upload SARIF to GitHub Security + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: build/reports/detekt/detekt.sarif + + - name: Upload HTML Reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: detekt-reports + path: '**/build/reports/detekt/' +``` + +**Key CI considerations:** +- Use `if: always()` to upload reports even on failure +- Upload SARIF for GitHub Security tab integration +- Fail the build if issues are found (default behavior) +- Cache Gradle dependencies for faster builds + +If the project uses Gradle toolchains, Detekt will resolve the proper JDK automatically. + +## Compose Rules +The Compose detekt ruleset is configured in `assets/detekt.yml.template`. Use that template as-is. +For compatibility information and latest rules, see: [Compose rules + detekt compatibility](https://mrmans0n.github.io/compose-rules/detekt/) + +## Suppressing Violations + +### Acceptable Suppressions for Compose + +The following suppressions are acceptable on `@Composable` functions only. + +#### `@Suppress("LongMethod")` +Composable UI functions declare layout trees and are naturally longer than business logic functions. + +```kotlin +@Suppress("LongMethod") +@Composable +fun ProductDetailScreen( + product: Product, + onAddToCart: () -> Unit, + onNavigateBack: () -> Unit +) { + Scaffold( + topBar = { /* AppBar with 10+ lines */ }, + bottomBar = { /* Actions with 10+ lines */ } + ) { padding -> + LazyColumn(Modifier.padding(padding)) { + item { /* Hero image section 15+ lines */ } + item { /* Title and price section 10+ lines */ } + item { /* Description section 10+ lines */ } + item { /* Specifications section 20+ lines */ } + item { /* Reviews section 15+ lines */ } + // Total: 80+ lines is normal for a detail screen + } + } +} +``` + +#### `@Suppress("LongParameterList")` +Route/Screen composables accept ViewModels, callbacks, modifiers, and navigation arguments. + +```kotlin +@Suppress("LongParameterList") +@Composable +fun ProductDetailRoute( + productId: String, + viewModel: ProductDetailViewModel = hiltViewModel(), + navigator: ProductNavigator, + onAddToCart: (Product) -> Unit, + onShareProduct: (Product) -> Unit, + modifier: Modifier = Modifier +) { + // 6+ parameters is normal for feature entry points +} +``` + +#### `@Suppress("CyclomaticComplexMethod")` +Composables handling UI state often have multiple `when` branches for different states. + +```kotlin +@Suppress("CyclomaticComplexMethod") +@Composable +fun ProductsContent(state: ProductsUiState) { + when (state) { + is Loading -> LoadingIndicator() + is Empty -> EmptyState() + is Error -> ErrorMessage(state.message) + is Success -> when { + state.products.isEmpty() -> EmptyProductsList() + state.isFiltered -> FilteredProductsList(state.products, state.filter) + else -> ProductsList(state.products) + } + } + // Multiple when branches for UI states is normal +} +``` + +**Placement:** Place `@Suppress` directly above `@Composable`: +```kotlin +@Suppress("LongMethod", "CyclomaticComplexMethod") +@Composable +fun MyScreen() { /* ... */ } +``` + +### Targeted Suppressions + +#### `@Suppress` on Catch Parameters +For `TooGenericExceptionCaught`, place `@Suppress` on the catch parameter for maximum precision: + +```kotlin +try { + riskyOperation() +} catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + // Sometimes catching Exception is the right choice + // (e.g., unknown third-party library exceptions) + crashReporter.recordException(e) +} +``` + +This is more precise than suppressing the entire function. + +#### File-Level Suppressions + +Use `@file:Suppress` when an issue affects the entire file: + +```kotlin +@file:Suppress("MatchingDeclarationName") +package com.example.ui.view + +// File: AnAnimatedComposableExampleView.kt +// Contains helper enum + main composable + +enum class CirclePosition { START, END } + +@Composable +fun AnAnimatedComposableExampleView(...) { /* ... */ } +``` + +**Use `@file:Suppress` when:** + +- `MatchingDeclarationName`: primary composable plus supporting types (enums, sealed classes, data classes) share one file. +- `TooManyFunctions`: composable files with many tiny helpers. +- `MagicNumber`: UI files dense with layout constants. + +### Forbidden suppressions + +Do not suppress without fixing the root cause when: +- `ComplexMethod` in ViewModels or business logic → Refactor the code +- `LongParameterList` in data classes → Consider builder pattern or DSL +- `TooGenericExceptionCaught` when you can handle specific exceptions → Use specific catches +- `UnusedPrivateProperty` → Remove the property + +## Suppression rules + +Required: +- Fix the violation. Suppress only when the rule does not apply (e.g., `LongMethod` on a `@Composable`). +- Add a one-line `// Suppressed because ` comment next to every `@Suppress`. +- Use the narrowest scope: catch parameter > single declaration > `@file:Suppress`. +- Re-audit suppressions on every CI baseline regeneration. + +Forbidden: +- Suppressing rules in ViewModel, repository, use case, or other non-`@Composable` code without refactor. +- File-level suppression of `MagicNumber`, `ComplexMethod`, `LongParameterList` in data/domain layers. +- Adding a suppression labeled "temporary" without a tracked follow-up. diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/references/compose-patterns.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/compose-patterns.md new file mode 100644 index 000000000..f8d666f2f --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/compose-patterns.md @@ -0,0 +1,4092 @@ +# Jetpack Compose Patterns + +Required: Material 3, Navigation 3, adaptive layouts, edge-to-edge, lifecycle-aware state collection. Kotlin code aligns with [kotlin-patterns.md](/references/kotlin-patterns.md). Accessibility (semantics, touch targets, TalkBack) is mandatory - [android-accessibility.md](/references/android-accessibility.md). Theming via Material 3 semantic roles - [android-theming.md](/references/android-theming.md). All user-facing text via string resources - [android-i18n.md](/references/android-i18n.md). + +## Table of Contents + +1. [Screen Architecture](#screen-architecture) +2. [State Management](#state-management) + - [Loading and refresh UX](#loading-and-refresh-ux) +3. [Component Patterns](#component-patterns) +4. [Adaptive UI](#adaptive-ui) +5. [Theming & Design System](#theming--design-system) +6. [Previews & Testing](#previews--testing) +7. [Performance Optimization](#performance-optimization) +8. [Animation](#animation) +9. [Side Effects](#side-effects) +10. [Modifiers](#modifiers) +11. [Deprecated Patterns & Migrations](#deprecated-patterns--migrations) +12. [CompositionLocal](#compositionlocal) +13. [Lists & Scrolling](#lists--scrolling) +14. [View Composition Rules](#view-composition-rules) +15. [Forms & Input](#forms--input) + +## Screen Architecture + +### Feature Screen Pattern + +Split each feature screen into a `Route` (state collection + navigation glue) and a stateless `Screen` (pure UI). `Navigator` interfaces live in the feature module; implementations live in `app` (see [modularization.md](/references/modularization.md)). + +```kotlin +// feature-auth/presentation/AuthRoute.kt +@Composable +fun AuthRoute( + authNavigator: AuthNavigator, + modifier: Modifier = Modifier, + viewModel: AuthViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + // Collect one-time navigation events + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(viewModel.navigationEvents, lifecycleOwner) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.navigationEvents.collect { event -> + when (event) { + is AuthNavigationEvent.LoginSuccess -> authNavigator.navigateToMainApp() + is AuthNavigationEvent.RegisterSuccess -> authNavigator.navigateToMainApp() + } + } + } + } + + LoginScreen( + uiState = uiState, + onAction = viewModel::onAction, + onRegisterClick = authNavigator::navigateToRegister, + onForgotPasswordClick = authNavigator::navigateToForgotPassword, + modifier = modifier + ) +} + +// feature-auth/presentation/LoginScreen.kt +@Composable +fun LoginScreen( + uiState: AuthUiState, + onAction: (AuthAction) -> Unit, + onRegisterClick: () -> Unit, + onForgotPasswordClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box(modifier = modifier) { + when (uiState) { + AuthUiState.Loading -> LoadingScreen() + is AuthUiState.LoginForm -> AuthFormCard( + state = uiState, + onEmailChanged = { onAction(AuthAction.EmailChanged(it)) }, + onPasswordChanged = { onAction(AuthAction.PasswordChanged(it)) }, + onLoginClick = { onAction(AuthAction.LoginClicked) }, + onRegisterClick = onRegisterClick, + onForgotPasswordClick = onForgotPasswordClick + ) + is AuthUiState.Error -> ErrorContent(uiState.message, uiState.canRetry) { + onAction(AuthAction.Retry) + } + else -> Unit + } + } +} +``` + +Navigation setup, destinations, and `Navigator` interfaces: [android-navigation.md](/references/android-navigation.md). + +## Naming Conventions + +Follow these conventions when naming Compose functions: + +### Components + +Components are functions that emit UI elements. + +- **Name:** `UpperCamelCase` (e.g., `FancyButton`, `ScrollAwareHeader`) +- **Return Type:** `Unit` (does not return a value) +- **Parameters:** Should have a `modifier` parameter at the first optional position +- **Usage:** Uses the `modifier` parameter at the top of the UI root + +```kotlin +@Composable +fun FancyButton( + text: String, + modifier: Modifier = Modifier +) { + Text( + text = text, + modifier = modifier + ) +} +``` + +### Factory Functions + +Factory functions create objects or state and typically pair with `remember`. + +- **Name:** `lowerCamelCase` (e.g., `defaultStyle`, `rememberCoroutineScope`) +- **Return Type:** Returns a result (e.g., `Style`, `CoroutineScope`) +- **UI Emission:** Does not emit UI +- **Usage:** Uses `@Composable` only if it needs to `remember` or use `CompositionLocal` + +```kotlin +@Composable +fun defaultStyle(): Style = // ... + +@Composable +fun rememberCoroutineScope(): CoroutineScope = // ... +``` + +## State Management + +### Loading and refresh UX + +**Required:** keep **stable layout** and **preserved context** during loads and refresh — retain visible content, scroll position, and in-flight form state while network work runs. + +| Situation | Default | +|------------------------------------------------------------|---------------------------------------------------------------------------------------------------| +| First load, layout shape is known | Skeleton or placeholder in a **fixed-height** slot | +| Refresh while previous data exists | Keep previous content; show **inline** progress (pull-to-refresh, small indicator on the section) | +| Recalculate / refine result while an older result is valid | Keep old result visible; show "updating" until the new payload arrives | +| Empty and idle | Empty state copy, not a blocking spinner | +| Blocking work with **no** stable structure | Full-screen spinner is acceptable but should be rare | + +**Do not:** replace the whole screen with a spinner on every refresh, clear forms when a reload runs, or drop the last good result on transient errors. + +```kotlin +// Bad +@Composable +fun SummarySection(summary: SummaryUi?, isLoading: Boolean) { + if (isLoading) { + CircularProgressIndicator() + } else if (summary != null) { + SummaryContent(summary = summary, refreshing = false) + } +} + +// Good +@Composable +fun SummarySection(summary: SummaryUi?, isLoading: Boolean) { + SummaryCardSlot { + when { + summary != null -> SummaryContent(summary = summary, refreshing = isLoading) + isLoading -> SummarySkeleton() + else -> SummaryEmptyState() + } + } +} + +@Composable +private fun SummaryCardSlot(content: @Composable BoxScope.() -> Unit) { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 180.dp) + ) { + content() + } +} +``` + +Model **refreshing** as a flag on the content state (e.g. `isRefreshing` on a data class) or a small overlay, not as a mode that **hides** the main UI unless there is nothing to show yet. + +### Sealed Interface for UI State + +```kotlin +// feature-auth/presentation/viewmodel/AuthUiState.kt +sealed interface AuthUiState { + data object Loading : AuthUiState + + data class LoginForm( + val email: String = "", + val password: String = "", + val isLoading: Boolean = false, + val emailError: String? = null, + val passwordError: String? = null + ) : AuthUiState + + data class RegisterForm( + val email: String = "", + val password: String = "", + val confirmPassword: String = "", + val name: String = "", + val isLoading: Boolean = false, + val errors: Map = emptyMap() + ) : AuthUiState + + data class ForgotPasswordForm( + val email: String = "", + val isLoading: Boolean = false, + val emailError: String? = null, + val isEmailSent: Boolean = false + ) : AuthUiState + + data class Success(val user: User) : AuthUiState + + data class Error( + val message: String, + val canRetry: Boolean = true + ) : AuthUiState +} +``` + +### Actions Pattern for User Interactions + +```kotlin +// feature-auth/presentation/viewmodel/AuthActions.kt +sealed class AuthAction { + // Login form actions + data class EmailChanged(val email: String) : AuthAction() + data class PasswordChanged(val password: String) : AuthAction() + data object LoginClicked : AuthAction() + data object ShowRegisterForm : AuthAction() + data object ShowForgotPasswordForm : AuthAction() + + // Register form actions + data class NameChanged(val name: String) : AuthAction() + data class ConfirmPasswordChanged(val confirmPassword: String) : AuthAction() + data object RegisterSubmit : AuthAction() + data object ShowLoginForm : AuthAction() + + // Forgot password actions + data object ResetPasswordClicked : AuthAction() + + // Error handling + data object Retry : AuthAction() + data object ClearError : AuthAction() +} +``` + +### ViewModel with Form State + +Use delegation for shared behaviour (validation, analytics, feature flags); never an inheritance base class. See [kotlin-delegation.md](/references/kotlin-delegation.md). + +For process-death survival, include `SavedStateHandle` in ViewModels and persist critical UI state (forms, in-progress flows) using `savedStateHandle.getStateFlow()` for automatic restoration. + +```kotlin +// feature-auth/presentation/viewmodel/AuthViewModel.kt +interface AuthFormValidator { + fun validateEmail(email: String): String? + fun validatePassword(password: String): String? +} + +class DefaultAuthFormValidator @Inject constructor() : AuthFormValidator { + override fun validateEmail(email: String): String? = + if (email.contains("@")) null else "Invalid email" + + override fun validatePassword(password: String): String? = + if (password.length >= 8) null else "Password too short" +} + +// Navigation events (one-time events) +// These are internal to the feature and trigger navigation via AuthNavigator +sealed interface AuthNavigationEvent { + data object LoginSuccess : AuthNavigationEvent + data object RegisterSuccess : AuthNavigationEvent +} + +@HiltViewModel +class AuthViewModel @Inject constructor( + private val loginUseCase: LoginUseCase, + private val registerUseCase: RegisterUseCase, + private val resetPasswordUseCase: ResetPasswordUseCase, + private val savedStateHandle: SavedStateHandle, + validator: AuthFormValidator +) : ViewModel(), AuthFormValidator by validator { + + // UI State + private val _uiState = MutableStateFlow(AuthUiState.LoginForm()) + val uiState: StateFlow = _uiState.asStateFlow() + + // Preferred for one-shot navigation commands (unicast); see references/coroutines-patterns.md + private val _navigationEvents = Channel(Channel.BUFFERED) + val navigationEvents: Flow = _navigationEvents.receiveAsFlow() + + // Alternative: SharedFlow when several collectors need the same stream or replay is intended + // private val _navigationEvents = MutableSharedFlow( + // replay = 0, + // extraBufferCapacity = 1, + // onBufferOverflow = BufferOverflow.DROP_OLDEST + // ) + // val navigationEvents: SharedFlow = _navigationEvents.asSharedFlow() + + // Process-death survival: persist form state + private val email = savedStateHandle.getStateFlow("email", "") + + init { + // Restore email if saved + if (email.value.isNotEmpty()) { + _uiState.update { state -> + if (state is AuthUiState.LoginForm) { + state.copy(email = email.value) + } else state + } + } + } + + fun onAction(action: AuthAction) { + when (action) { + is AuthAction.EmailChanged -> { + savedStateHandle["email"] = action.email + updateLoginForm { + it.copy( + email = action.email, + emailError = validateEmail(action.email) + ) + } + } + is AuthAction.PasswordChanged -> updateLoginForm { + it.copy( + password = action.password, + passwordError = validatePassword(action.password) + ) + } + AuthAction.LoginClicked -> performLogin() + AuthAction.ShowForgotPasswordForm -> _uiState.value = AuthUiState.ForgotPasswordForm() + AuthAction.ShowRegisterForm -> _uiState.value = AuthUiState.RegisterForm() + is AuthAction.NameChanged -> updateRegisterForm { it.copy(name = action.name) } + is AuthAction.ConfirmPasswordChanged -> updateRegisterForm { + it.copy(confirmPassword = action.confirmPassword) + } + AuthAction.RegisterSubmit -> performRegistration() + AuthAction.ShowLoginForm -> _uiState.value = AuthUiState.LoginForm() + AuthAction.ResetPasswordClicked -> performPasswordReset() + AuthAction.Retry -> _uiState.value = AuthUiState.LoginForm() + AuthAction.ClearError -> _uiState.value = AuthUiState.LoginForm() + } + } + + private fun performLogin() { + val currentState = _uiState.value as? AuthUiState.LoginForm ?: return + + viewModelScope.launch { + _uiState.update { AuthUiState.Loading } + + loginUseCase(currentState.email, currentState.password).fold( + onSuccess = { user -> + // Emit navigation event - AuthRoute will call authNavigator.navigateToMainApp() + _navigationEvents.send(AuthNavigationEvent.LoginSuccess) // Channel + // _navigationEvents.emit(AuthNavigationEvent.LoginSuccess) // SharedFlow + }, + onFailure = { error -> + _uiState.update { + AuthUiState.Error(error.message ?: "Login failed", canRetry = true) + } + } + ) + } + } + + // Other helper methods omitted for brevity (updateLoginForm, updateRegisterForm, etc.) +} +``` + +### Initial Data Load Strategies + +| Pattern | Use when | Avoid when | +|------------------------------------|-----------------------------------------------------------------------------------|--------------------------------------------------| +| ViewModel `init {}` | Data is always needed; no retry / refresh; one-shot fetch. | You need pull-to-refresh, retry, or trigger UI. | +| `LaunchedEffect(Unit)` | Trivial, UI-scoped one-shot load with no retry. | Logic must survive config change or be retried. | +| `.onStart + stateIn` | Reactive screen backed by a `Flow`; survives config changes. | Users need explicit retry / refresh. | +| Reactive trigger + `flatMapLatest` | Pull-to-refresh, retry, action-driven reload; reactive screen. | Trivial one-shot screens (overkill). | + +Default: reactive trigger + `flatMapLatest` for any screen that may need retry or refresh; `.onStart + stateIn` otherwise. + +#### ViewModel `init {}` + +```kotlin +class MyViewModel(private val repository: MyRepository) : ViewModel() { + private val _uiState = MutableStateFlow(UiState.Loading) + val uiState = _uiState.asStateFlow() + + init { loadData() } + + private fun loadData() { + viewModelScope.launch { _uiState.value = repository.getData() } + } +} +``` + +#### `LaunchedEffect(Unit)` + +```kotlin +@Composable +fun MyScreen(viewModel: MyViewModel = hiltViewModel()) { + LaunchedEffect(Unit) { viewModel.loadData() } +} +``` + +#### `.onStart + stateIn` + +```kotlin +class MyViewModel(repository: MyRepository) : ViewModel() { + val uiState: StateFlow = repository.dataFlow() + .map { UiState.Success(it) } + .onStart { emit(UiState.Loading) } + .catch { emit(UiState.Error(it)) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = UiState.Loading + ) +} +``` + +#### Reactive trigger + `flatMapLatest` + +```kotlin +class MyViewModel(private val repository: MyRepository) : ViewModel() { + private val loadTrigger = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + val uiState: StateFlow = loadTrigger + .onStart { emit(Unit) } + .flatMapLatest { + repository.dataFlow() + .map { UiState.Success(it) } + .onStart { emit(UiState.Loading) } + .catch { emit(UiState.Error(it)) } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = UiState.Loading + ) + + fun retry() { loadTrigger.tryEmit(Unit) } +} +``` + +### State Collection with Lifecycle + +```kotlin +@Composable +fun AuthRoute( + authNavigator: AuthNavigator, + viewModel: AuthViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + // Collect one-time navigation events using repeatOnLifecycle + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(viewModel.navigationEvents, lifecycleOwner) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.navigationEvents.collect { event -> + when (event) { + is AuthNavigationEvent.LoginSuccess -> authNavigator.navigateToMainApp() + is AuthNavigationEvent.RegisterSuccess -> authNavigator.navigateToMainApp() + } + } + } + } + + LoginScreen( + uiState = uiState, + onAction = viewModel::onAction, + onRegisterClick = authNavigator::navigateToRegister, + onForgotPasswordClick = authNavigator::navigateToForgotPassword + ) +} +``` + +### Lifecycle-Aware Flow Collection for Side Effects + +Use `collectAsStateWithLifecycle()` for state observation. For side effects (toasts, analytics, dialogs) that cannot use state, collect flows inside `LaunchedEffect` with lifecycle awareness. + +```kotlin +@Composable +fun AuthScreen( + viewModel: AuthViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + + // For single flow: use flowWithLifecycle + LaunchedEffect(viewModel.toastEvents, lifecycleOwner) { + viewModel.toastEvents + .flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED) + .collect { message -> + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + } + + LoginScreen( + uiState = uiState, + onAction = viewModel::onAction + ) +} +``` + +For multiple flows or complex scoped operations, use `repeatOnLifecycle`: + +```kotlin +@Composable +fun AuthScreen( + viewModel: AuthViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + + // For multiple flows: use repeatOnLifecycle + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.toastEvents.collect { message -> + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + } + + launch { + viewModel.analyticsEvents.collect { event -> + // Log analytics event + } + } + + launch { + viewModel.dialogEvents.collect { dialog -> + // Show dialog based on event + } + } + } + } + + LoginScreen( + uiState = uiState, + onAction = viewModel::onAction + ) +} +``` + +**Required:** match the collector to the work: + +- UI-bound state → `collectAsStateWithLifecycle()`. +- Single side-effect stream → `flowWithLifecycle`. +- Multiple streams or complex lifecycle scopes → `repeatOnLifecycle`. +- These APIs stop leaked collectors and idle background collection during lifecycle churn. + +### Primitive State Specializations + +Avoid boxing overhead - use type-specific state holders: + +```kotlin +var count by remember { mutableIntStateOf(0) } // not mutableStateOf(0) +var progress by remember { mutableFloatStateOf(0f) } // not mutableStateOf(0f) +var timestamp by remember { mutableLongStateOf(0L) } // not mutableStateOf(0L) +var enabled by remember { mutableStateOf(true) } // Boolean has no specialization +``` + +### snapshotFlow - Compose State to Flow + +Converts Compose state reads into a Kotlin Flow. Use inside `LaunchedEffect` to react to state changes with Flow operators (debounce, distinctUntilChanged, filter). + +```kotlin +@Composable +fun SearchScreen(viewModel: SearchViewModel) { + var query by remember { mutableStateOf("") } + + LaunchedEffect(Unit) { + snapshotFlow { query } + .debounce(300) + .distinctUntilChanged() + .collect { viewModel.search(it) } + } + + TextField(value = query, onValueChange = { query = it }) +} +``` + +```kotlin +// Bad: captures initial value only +LaunchedEffect(Unit) { + viewModel.search(query) +} + +// Bad: restarts the effect on every keystroke +LaunchedEffect(query) { + delay(300) + viewModel.search(query) +} +``` + +### SnapshotStateList and SnapshotStateMap + +Observable collections that trigger recomposition on structural changes. + +```kotlin +val items = remember { mutableStateListOf() } +val cache = remember { mutableStateMapOf() } + +// These trigger recomposition: +items.add(Item(1, "First")) +items[0] = Item(1, "Updated") +items.removeAt(0) +cache["key"] = user +``` + +**Gotcha:** In-place mutation of elements does NOT trigger recomposition: + +```kotlin +// Bad: in-place mutation +items[0].name = "Updated" + +// Good: replace via copy +items[0] = items[0].copy(name = "Updated") +``` + +For ViewModel-level state, prefer `StateFlow>` (see [Persistent Collections](#persistent-collections-for-performance)) over `SnapshotStateList`. Use `SnapshotStateList` only for UI-local state. + +### remember, rememberSaveable, and rememberSerializable + +These APIs differ in **how long** state is kept and **what types** you can store. Official overview: [State lifespans in Compose](https://developer.android.com/develop/ui/compose/state-lifespans). + +| | `remember` | `rememberSaveable` | `rememberSerializable` | +|---------------------------------------|----------------------|----------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------| +| Survives recompositions | Yes | Yes | Yes | +| Survives activity / config recreation | No | Yes (restored value may be a **new** instance, `==` but not `===`) | Same as `rememberSaveable` | +| Survives process death | No | Yes | Yes | +| Custom / complex types | Any (in memory only) | Primitives, `String`, arrays, built-in support for common types (`List`, `Map`, `State`, …), **`@Parcelize`**, or a **custom `Saver`** | Types you can represent with **`kotlinx.serialization`** (`@Serializable`) | + +**When to use which** + +- **`remember`** - Default for composable-scoped state that can be recreated without harming UX: animation state, internal helpers, `LazyListState` when you do not need process death, ephemeral expand/collapse, hover. **Do not** store irreplaceable user input or form data in plain `remember`; it is cleared on configuration change and process death. + +- **`rememberSaveable`** - User-visible state the app cannot easily reload from elsewhere: text fields, toggles, scroll position, selected tab, navigation arguments mirrored in UI. Use this when your type is already `Parcelable`, fits the built-in `Saver` rules, or you hand-write a `Saver` / `mapSaver` / `listSaver`. + +- **`rememberSerializable`** - Same persistence guarantees as `rememberSaveable`, but **automatic persistence for `@Serializable` models** via `kotlinx.serialization`. Use it when your domain or UI model is already (or can be) marked `@Serializable`; use **`rememberSaveable`** for primitives, `Parcelable`, or manual `Saver`s when you are not using kotlinx.serialization. + +Both saveable variants serialize into a `Bundle`, so restored values are **equivalent** copies, not the same object identity. + +#### rememberSaveable with custom types + +`rememberSaveable` survives process death and configuration changes. Custom types need a `Saver`, `@Parcelize`, or supported primitives/collections: + +```kotlin +// Option 1: Saver (pure Kotlin, no Android dependency) +data class FilterState(val category: String, val sortOrder: String) + +val filterSaver = Saver>( + save = { listOf(it.category, it.sortOrder) }, + restore = { FilterState(category = it[0], sortOrder = it[1]) } +) + +var filter by rememberSaveable(stateSaver = filterSaver) { + mutableStateOf(FilterState("all", "newest")) +} + +// Option 2: @Parcelize (requires kotlin-parcelize plugin) +@Parcelize +data class FilterState(val category: String, val sortOrder: String) : Parcelable + +var filter by rememberSaveable { mutableStateOf(FilterState("all", "newest")) } + +// Option 3: mapSaver for quick key-value serialization +val filterSaver = mapSaver( + save = { mapOf("category" to it.category, "sortOrder" to it.sortOrder) }, + restore = { FilterState(it["category"] as String, it["sortOrder"] as String) } +) +``` + +#### rememberSerializable with @Serializable types + +Use when your state is modeled with kotlinx.serialization (same lifecycle as `rememberSaveable`; do not wrap the same state in both `rememberSaveable` and `rememberSerializable`). The `MutableState` overload infers a `KSerializer` for the reified type `T`: + +```kotlin +import androidx.compose.runtime.saveable.rememberSerializable +import kotlinx.serialization.Serializable + +@Serializable +data class FilterState(val category: String, val sortOrder: String) + +var filter by rememberSerializable { mutableStateOf(FilterState("all", "newest")) } +``` + +For a non-state value, use the overload whose `init` returns `T` directly. If you need an explicit `KSerializer` (custom serializers, polymorphism, etc.), use the overloads that take `serializer:` or `stateSerializer:` - see [`rememberSerializable`](https://developer.android.com/reference/kotlin/androidx/compose/runtime/saveable/package-summary#rememberSerializable(kotlin.Array,kotlinx.serialization.KSerializer,androidx.savedstate.serialization.SavedStateConfiguration,kotlin.Function0)). + +For other APIs that sit between these lifespans (for example **`retain`**, which survives configuration change but not process death), see the same [State lifespans](https://developer.android.com/develop/ui/compose/state-lifespans) guide. + +### Edge-to-Edge (Mandatory on API 36) + +Starting with Android 16 (API 36), edge-to-edge is mandatory and cannot be opted out of. The `R.attr#windowOptOutEdgeToEdgeEnforcement` attribute is deprecated and disabled. All apps must handle system bar insets properly. + +```kotlin +// app/MainActivity.kt +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + enableEdgeToEdge() + + setContent { + AppTheme { + Scaffold( + modifier = Modifier.fillMaxSize() + ) { innerPadding -> + MainNavigation( + modifier = Modifier.padding(innerPadding) + ) + } + } + } + } +} +``` + +**Key requirements:** + +- Call `enableEdgeToEdge()` in `onCreate()` before `setContent` +- Use `Scaffold` which provides `innerPadding` that accounts for system bars +- Apply `innerPadding` to your content to avoid overlap with status bar and navigation bar +- For scrollable content, use `Modifier.consumeWindowInsets()` and `Modifier.windowInsetsPadding()` +- For bottom sheets, FABs, and overlays, use `WindowInsets.navigationBars` or `WindowInsets.ime` + +```kotlin +@Composable +fun ScrollableContentWithInsets(modifier: Modifier = Modifier) { + LazyColumn( + modifier = modifier, + contentPadding = WindowInsets.systemBars.asPaddingValues() + ) { + items(100) { index -> + Text("Item $index", modifier = Modifier.padding(16.dp)) + } + } +} +``` + +**Picking the right `safe*Padding` modifier:** + +| Modifier | Insets applied | Use for | +|----------------------------------|---------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------| +| `Modifier.safeDrawingPadding()` | System bars + IME (keyboard) | Default for **text input screens** and any surface that must stay clear of the keyboard. | +| `Modifier.safeContentPadding()` | System bars + IME + display cutouts + waterfall | Default for **top-level content surfaces** (AnimatedPane, full-screen hosts). Use when content could land behind a camera cutout or curved edge. | +| `Modifier.safeGesturesPadding()` | System gesture regions (back-gesture edges, nav handle) | **Draggable** UI (sliders, pull-to-refresh, horizontal pagers near screen edges) to avoid gesture conflicts. | + +Rule of thumb: start with `safeDrawingPadding()` for form screens, `safeContentPadding()` for hosts/panes, and add `safeGesturesPadding()` on any composable that consumes drag gestures. Do not stack more than one `safe*Padding` on the same node. + +**Target SDK floor:** the inset APIs below require **target SDK 35+**. `targetSdk = 37` is the project default; 35 is the minimum on which these APIs are guaranteed available. + +#### IME (soft keyboard) insets + +Wire IME insets in two places: + +1. **Manifest:** every Activity that hosts text input must set `android:windowSoftInputMode="adjustResize"`. `SOFT_INPUT_ADJUST_RESIZE` is deprecated; do not use the runtime constant. +2. **Composable:** apply IME insets to the input container. Use **one** of the patterns below. Combining them double-pads. + +**Use `Modifier.fitInside(WindowInsetsRulers.Ime.current)` by default.** It fits content inside the IME inset regardless of upstream `consumeWindowInsets` calls, so an ancestor that forgets to consume cannot break it. + +```kotlin +Scaffold { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .consumeWindowInsets(innerPadding) + .fitInside(WindowInsetsRulers.Ime.current) + .verticalScroll(rememberScrollState()) + ) { /* TextField + content */ } +} +``` + +**Use `Modifier.imePadding()`** when `WindowInsetsRulers` is unavailable. Two ordering rules apply: + +- `imePadding()` **must come before** `Modifier.verticalScroll(...)`. Reversing the order makes the keyboard cover the focused field on tall content. +- Do **not** combine `imePadding()` with `Scaffold(contentWindowInsets = WindowInsets.safeDrawing)`. `safeDrawing` already includes the IME. + +```kotlin +// CORRECT: default contentWindowInsets does not include IME; imePadding() applies it once. +Scaffold { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .consumeWindowInsets(innerPadding) + .imePadding() + .verticalScroll(rememberScrollState()) + ) { /* TextField + content */ } +} + +// WRONG: IME padding applied twice (once via safeDrawing, once via imePadding()). +Scaffold(contentWindowInsets = WindowInsets.safeDrawing) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .imePadding() + .verticalScroll(rememberScrollState()) + ) { /* … */ } +} +``` + +For **non-Scaffold** layouts, consume the parent insets explicitly so children do not re-apply them: + +```kotlin +// CORRECT: safeDrawingPadding consumes insets for descendants. +Box(modifier = Modifier.safeDrawingPadding()) { + Column(modifier = Modifier.imePadding()) { /* TextField + content */ } +} + +// WRONG: outer padding does not consume insets; imePadding() double-pads. +Box(modifier = Modifier.padding(WindowInsets.safeDrawing.asPaddingValues())) { + Column(modifier = Modifier.imePadding()) { /* … */ } +} +``` + +**API 37: keyboard visibility after rotation.** At target SDK 37, the platform no longer restores IME visibility across configuration changes. For inputs that must stay open after rotation: + +- Manifest: set `android:windowSoftInputMode="stateAlwaysVisible|adjustResize"`. Keep `adjustResize` in the same value so the inset patterns above still apply. +- Runtime: call `WindowInsetsControllerCompat.show(WindowInsetsCompat.Type.ime())` from `onCreate` after `setContent`, and re-issue it on configuration-change recomposition (e.g. `LaunchedEffect(configuration)`). + +```kotlin +val view = LocalView.current +val configuration = LocalConfiguration.current +LaunchedEffect(configuration) { + val window = (view.context as Activity).window + WindowCompat.getInsetsController(window, view).show(WindowInsetsCompat.Type.ime()) +} +``` + +Forbidden: relying on the platform to reopen the keyboard automatically. The behaviour shipped in earlier APIs is removed at target 37. + +#### System bar appearance & contrast + +Status- and navigation-bar icon legibility is controlled in the theme/Activity, not in screen code. + +- **`ComponentActivity.enableEdgeToEdge`** (default entry point) auto-flips icon colors per system theme. **Do not** set `isAppearanceLightStatusBars` / `isAppearanceLightNavigationBars` manually when using this entry point. +- **`WindowCompat.enableEdgeToEdge`** does **not** auto-flip. Set both manually: + + ```kotlin + @Composable + fun AppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as? Activity)?.window ?: return@SideEffect + val controller = WindowCompat.getInsetsController(window, view) + controller.isAppearanceLightStatusBars = !darkTheme + controller.isAppearanceLightNavigationBars = !darkTheme + } + } + MaterialTheme(content = content) + } + ``` + +- **Three-button nav contrast scrim:** `enableEdgeToEdge` defaults `window.isNavigationBarContrastEnforced = true`, which paints a translucent scrim under three-button navigation. When the screen draws its own bottom bar (`BottomAppBar`, `NavigationBar`, `NavigationSuiteScaffold` with a bar), set it to `false` so the bar colour reaches the screen edge: + + ```kotlin + // In MainActivity.onCreate(), after enableEdgeToEdge() + if (Build.VERSION.SDK_INT >= 29) { + window.isNavigationBarContrastEnforced = false + } + ``` + +- **Status-bar protection scrim:** use when content scrolls under a translucent status bar and icons need extra contrast. + + ```kotlin + @Composable + fun StatusBarProtection(color: Color = MaterialTheme.colorScheme.surfaceContainer) { + Spacer( + modifier = Modifier + .fillMaxWidth() + .height( + with(LocalDensity.current) { + (WindowInsets.statusBars.getTop(this) * 1.2f).toDp() + } + ) + .background( + brush = Brush.verticalGradient( + colors = listOf(color, color.copy(alpha = 0.8f), Color.Transparent) + ) + ) + ) + } + ``` + + Render it **after** the main content in the same `Box`/`Scaffold` so it sits on top of the scrolling region. + +#### NavigationSuiteScaffold and adaptive-pane scaffolds + +`NavigationSuiteScaffold` and the `*PaneScaffold` family (`ListDetailPaneScaffold`, `SupportingPaneScaffold`) **do not propagate `PaddingValues`** to their inner content lambdas. The scaffolds manage insets for their own chrome (rail, bar, drawer); each pane is responsible for its own content insets. + +- Apply insets per-pane / per-screen, e.g. `LazyColumn(contentPadding = …)` and `Modifier.safeContentPadding()` on `AnimatedPane`. +- **Do not** wrap the `NavigationSuiteScaffold` itself in `safeDrawingPadding()` / `safeContentPadding()` - that clips the chrome and breaks edge-to-edge. + +#### Full-screen Dialogs + +`AlertDialog` handles insets internally. A **full-screen** `Dialog` (opts out of platform width sizing **and** fills the screen) requires an explicit edge-to-edge opt-in: + +```kotlin +Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ) +) { + Surface(modifier = Modifier.fillMaxSize().safeDrawingPadding()) { /* content */ } +} +``` + +If the dialog uses platform width or does not call `fillMaxSize()`, leave `decorFitsSystemWindows` at its default. Flipping it on a non-full-screen dialog misaligns content. + +#### Edge-to-edge checklist + +Run before considering an Activity edge-to-edge complete: + +- [ ] `enableEdgeToEdge()` is called in every Activity's `onCreate()` before `setContent`. +- [ ] `android:windowSoftInputMode="adjustResize"` is set in `AndroidManifest.xml` for every Activity that hosts a soft keyboard. +- [ ] Every `TextField` / `OutlinedTextField` / `BasicTextField` has an ancestor that applies IME insets (`fitInside(WindowInsetsRulers.Ime.current)`, `imePadding()`, `safeDrawingPadding()`, `safeContentPadding()`, `safeGesturesPadding()`, or `Scaffold(contentWindowInsets = WindowInsets.safeDrawing)`). +- [ ] Lists pass insets to `contentPadding`, **not** as a parent `Modifier.padding()` (otherwise content cannot scroll behind the system bars). +- [ ] FABs and floating overlays sit inside a `Scaffold` **or** apply `Modifier.safeDrawingPadding()`. +- [ ] If using `WindowCompat.enableEdgeToEdge` (not the `ComponentActivity` API), `isAppearanceLightStatusBars` / `isAppearanceLightNavigationBars` are wired to the theme. +- [ ] If the Activity draws its own bottom bar, `window.isNavigationBarContrastEnforced = false` is set (SDK 29+). +- [ ] `./gradlew build` succeeds. + +**Do NOT:** + +- Set `fitsSystemWindows` in XML +- Use `windowOptOutEdgeToEdgeEnforcement` -- it is disabled on API 36 +- Assume the content area excludes system bars +- Apply `safe*Padding` to a `NavigationSuiteScaffold` or `*PaneScaffold` parent - apply it inside each pane instead +- Combine `Scaffold(contentWindowInsets = WindowInsets.safeDrawing)` with `Modifier.imePadding()` on the same column (double padding) + +### Predictive Back (Mandatory on API 36) + +Starting with Android 16 (API 36), predictive back system animations are enabled by default. `onBackPressed` is no longer called and `KeyEvent.KEYCODE_BACK` is not dispatched. + +**Migration requirements:** + +- Use `BackHandler` from `androidx.activity.compose` for all back handling +- Use `OnBackInvokedCallback` for non-Compose Activity/Fragment code +- Do **not** set `android:enableOnBackInvokedCallback="false"` as a permanent fix -- this is only a temporary escape hatch +- Register back callbacks ahead of time so the system can play predictive animations + +```kotlin +// Correct: Use BackHandler (Compose) +@Composable +fun MyScreen(onNavigateBack: () -> Unit) { + BackHandler { + onNavigateBack() + } + // Screen content +} +``` + +```kotlin +// Correct: OnBackInvokedCallback (non-Compose, API 33+) +class MyActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + onBackInvokedDispatcher.registerOnBackInvokedCallback( + OnBackInvokedDispatcher.PRIORITY_DEFAULT + ) { + handleBack() + } + } + } +} +``` + +**Do NOT:** + +- Override `onBackPressed()` -- it is no longer called on API 36 +- Dispatch `KeyEvent.KEYCODE_BACK` -- it is no longer dispatched +- Use `android:enableOnBackInvokedCallback="false"` as a permanent solution + +### Adaptive Layouts (Mandatory on API 36+ for Large Screens) + +Starting with Android 16 (API 36) and reaffirmed at API 37 (Android 17), orientation, resizability, and aspect-ratio restrictions are ignored on displays with smallest width >= 600dp. Apps targeting SDK 37 fill the entire display window regardless of declared constraints; manifest attributes that contradict adaptive rendering are silently dropped. + +**Ignored on large screens (API 36 and API 37):** + +- `screenOrientation` manifest attribute +- `resizableActivity="false"` +- `minAspectRatio` / `maxAspectRatio` +- `setRequestedOrientation()` / `getRequestedOrientation()` + +**Exceptions:** + +- Games (based on `android:appCategory="game"`) - still honored at API 37; verify against the [Android 17 migration guide](https://developer.android.com/about/versions/17/migration) before relying on it for production releases. +- Screens smaller than `sw600dp`. + +**Build adaptive layouts by default:** + +- Use `WindowSizeClass` to adapt layouts to any screen size +- Use `NavigationSuiteScaffold` for responsive navigation (auto-switches bar/rail/drawer) +- Use `NavigableListDetailPaneScaffold` for list-detail patterns (built-in nav + predictive back) +- Use `NavigableSupportingPaneScaffold` for main + supporting content patterns +- Save and restore UI state properly -- rotation causes activity re-creation +- Test on tablets, foldables, and desktop windowing modes + +**Dependencies** (all included in the `adaptive` bundle): + +```kotlin +implementation(libs.bundles.adaptive) +``` + +```kotlin +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun AdaptiveScreen( + windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo() +) { + val isCompact = windowAdaptiveInfo.windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact + + if (isCompact) { + CompactLayout() + } else { + ExpandedLayout() + } +} +``` + +### Handling System Back Button + +Use `BackHandler` from `androidx.activity.compose` to intercept system back button presses in Compose: + +```kotlin +@Composable +fun ImageDetailScreen( + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + var isZoomed by remember { mutableStateOf(false) } + + // Intercept back press when zoomed - exits zoom mode instead of screen + BackHandler(enabled = isZoomed) { + isZoomed = false + } + + Column(modifier = modifier) { + IconButton(onClick = onBackClick) { + Icon(painterResource(R.drawable.ic_back), "Back") + } + + ZoomableImage( + isZoomed = isZoomed, + onZoomChange = { isZoomed = it } + ) + } +} +``` + +**Common Use Cases:** + +1. **Unsaved Changes Warning** + +```kotlin +@Composable +fun FormScreen( + viewModel: FormViewModel, + onNavigateBack: () -> Unit +) { + val hasUnsavedChanges by viewModel.hasUnsavedChanges.collectAsStateWithLifecycle() + var showExitDialog by remember { mutableStateOf(false) } + + BackHandler(enabled = hasUnsavedChanges) { + showExitDialog = true + } + + if (showExitDialog) { + AlertDialog( + onDismissRequest = { showExitDialog = false }, + title = { Text("Unsaved Changes") }, + text = { Text("Are you sure you want to exit without saving?") }, + confirmButton = { + TextButton(onClick = { + viewModel.discardChanges() + onNavigateBack() + }) { + Text("Discard") + } + }, + dismissButton = { + TextButton(onClick = { showExitDialog = false }) { + Text("Cancel") + } + } + ) + } + + FormContent(viewModel = viewModel) +} +``` + +1. **Multi-Step Flow Navigation** + +```kotlin +@Composable +fun OnboardingScreen( + onComplete: () -> Unit, + onCancel: () -> Unit +) { + var currentStep by remember { mutableStateOf(0) } + + // Navigate to previous step on back press, exit on first step + BackHandler { + if (currentStep > 0) { + currentStep-- + } else { + onCancel() + } + } + + when (currentStep) { + 0 -> WelcomeStep(onNext = { currentStep++ }) + 1 -> PermissionsStep(onNext = { currentStep++ }, onBack = { currentStep-- }) + 2 -> PreferencesStep(onNext = onComplete, onBack = { currentStep-- }) + } +} +``` + +1. **Bottom Sheet or Modal State** + +```kotlin +@Composable +fun ScreenWithSheet( + onNavigateBack: () -> Unit +) { + var showBottomSheet by remember { mutableStateOf(false) } + + // Close bottom sheet on back press instead of exiting screen + BackHandler(enabled = showBottomSheet) { + showBottomSheet = false + } + + Scaffold( + floatingActionButton = { + FloatingActionButton(onClick = { showBottomSheet = true }) { + Icon(painterResource(R.drawable.ic_filter), "Filter") + } + } + ) { padding -> + ContentList(modifier = Modifier.padding(padding)) + + if (showBottomSheet) { + ModalBottomSheet(onDismissRequest = { showBottomSheet = false }) { + FilterContent() + } + } + } +} +``` + +**Required:** + +- Innermost enabled `BackHandler` handles the gesture first. +- Gate interception with the `enabled` flag. +- Registration ends when the composable leaves the composition. + +**Forbidden:** + +- Intercepting back with no path off the screen. + +## Component Patterns + +### Stateless, Reusable Components + +```kotlin +// core/ui/components/AuthFormCard.kt +@Composable +fun AuthFormCard( + state: AuthUiState.LoginForm, + onEmailChanged: (String) -> Unit, + onPasswordChanged: (String) -> Unit, + onLoginClick: () -> Unit, + onRegisterClick: () -> Unit, + onForgotPasswordClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card(modifier = modifier) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text("Welcome back", style = MaterialTheme.typography.titleMedium) + OutlinedTextField( + value = state.email, + onValueChange = onEmailChanged, + label = { Text("Email") }, + isError = state.emailError != null + ) + OutlinedTextField( + value = state.password, + onValueChange = onPasswordChanged, + label = { Text("Password") }, + visualTransformation = PasswordVisualTransformation(), + isError = state.passwordError != null + ) + Button( + onClick = onLoginClick, + enabled = state.email.isNotBlank() && state.password.isNotBlank() && !state.isLoading + ) { + Text(if (state.isLoading) "Signing in..." else "Login") + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + TextButton(onClick = onRegisterClick) { Text("Create account") } + TextButton(onClick = onForgotPasswordClick) { Text("Forgot password?") } + } + } + } +} +``` + +### Adaptive List Components + +```kotlin +// core/ui/components/AuthActivityList.kt +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun AuthActivityList( + events: List, + isLoadingMore: Boolean = false, + onItemClick: (AuthEvent) -> Unit, + onLoadMore: () -> Unit, + windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(), + modifier: Modifier = Modifier +) { + val isWideScreen = windowAdaptiveInfo.windowSizeClass.widthSizeClass != WindowWidthSizeClass.Compact + + LazyColumn( + modifier = modifier, + contentPadding = PaddingValues(horizontal = if (isWideScreen) 32.dp else 16.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items( + items = events, + key = { authEventKey(it) } + ) { event -> + AuthEventCard( + event = event, + onClick = { onItemClick(event) }, + modifier = Modifier.fillMaxWidth() + ) + } + + if (isLoadingMore) { + item { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(modifier = Modifier.size(48.dp)) + } + } + } + + // Load more trigger: only when not already loading and reached end + if (!isLoadingMore && events.isNotEmpty()) { + item { + LaunchedEffect(events.size) { + onLoadMore() + } + } + } + } +} + +private fun authEventKey(event: AuthEvent): String = when (event) { + is AuthEvent.SessionRefreshed -> "refreshed-${event.timestamp}" + is AuthEvent.SessionExpired -> "expired-${event.reason}" + is AuthEvent.Error -> "error-${event.message}-${event.retryable}" +} +``` + +### Shared Loading & Error States + +```kotlin +// core/ui/components/loading/ +@Composable +fun LoadingScreen( + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + CircularProgressIndicator() + Text( + text = "Loading...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +fun ErrorContent( + message: String, + canRetry: Boolean, + onRetry: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + if (canRetry) { + Button(onClick = onRetry) { + Text("Retry") + } + } + } + } +} +``` + +### Card Variants (Filled / Outlined / Elevated) + +M3 ships three `Card` variants. Picking the right one is purely a function of how much the card needs to **separate from its background**, not how "important" the content is. Mixing variants on the same surface is the most common slip-up - pick one per region and stick with it. + +| Variant | Composable | Surface role at rest | Use when | +|----------|----------------|-------------------------------------|-----------------------------------------------------------------------------------------------| +| Filled | `Card` | `surfaceContainerHighest` | Card sits directly on `surface`; default choice for list items, content tiles | +| Outlined | `OutlinedCard` | `surface` + `outlineVariant` border | Low-emphasis grouping; content-heavy lists where elevation noise hurts (table rows, settings) | +| Elevated | `ElevatedCard` | `surfaceContainerLow` + 1dp shadow | Card must read as floating over a busy/photo background; rarely needed otherwise | + +```kotlin +@Composable +fun ProductCard(product: Product, onClick: () -> Unit) { + Card(onClick = onClick) { + ProductCardContent(product) + } +} + +@Composable +fun SettingRow(setting: Setting, onClick: () -> Unit) { + OutlinedCard(onClick = onClick) { + SettingRowContent(setting) + } +} + +@Composable +fun FloatingHero(item: Item, onClick: () -> Unit) { + ElevatedCard(onClick = onClick) { + HeroContent(item) + } +} +``` + +`Card` / `OutlinedCard` / `ElevatedCard` already pull the right surface, content color, border, and (for Elevated) shadow from `MaterialTheme`. Don't pass `colors = CardDefaults.cardColors(containerColor = ...)` to swap variants - use the dedicated composable instead, otherwise the on-color and border defaults silently drift out of sync. See [Color Pairing Rules](/references/android-theming.md#color-pairing-rules) and [Surface Container Hierarchy](/references/android-theming.md#surface-container-hierarchy) for the underlying tokens. + +#### Clickable card → use the `onClick` overload + +`Card { ... }` is a static container. The moment the card is tappable, switch to the `Card(onClick = ...)` overload (same for `OutlinedCard` / `ElevatedCard`) - it wires up the M3 ripple, focus ring, hover state, and `Role.Button` semantics that a `.clickable { }` modifier on a static card silently misses. + +```kotlin +Card( + onClick = onClick, + modifier = Modifier.semantics { contentDescription = product.name }, +) { + ProductCardContent(product) +} +``` + +#### Variant anti-patterns + +- **Don't elevate by default.** `ElevatedCard` adds a real shadow; using it for every list item produces the cluttered MD2-style look M3 was designed to retire. Default to `Card`. +- **Don't mix variants in the same list.** A grid of `Card`s with one `ElevatedCard` reads as a bug, not emphasis. Use selection state, a badge, or a `surfaceContainerHigh` background instead. +- **Don't outline a card that already sits on `surfaceContainer*`.** The border vanishes against the tonal step. Use `OutlinedCard` only when the parent is `surface`. + +### Touch Targets + +Every interactive element must have a minimum touch area of **48x48dp**. If the visual element is +smaller, expand the touch area. Keep at least **8dp** between adjacent targets to prevent mis-taps. + +```kotlin +IconButton(onClick = onClose) { + Icon(Icons.Default.Close, contentDescription = "Close") +} + +Box( + modifier = Modifier + .minimumInteractiveComponentSize() + .clickable { onAction() }, + contentAlignment = Alignment.Center +) { + Icon(modifier = Modifier.size(24.dp), imageVector = Icons.Default.Star, contentDescription = "Rate") +} +``` + +### Haptic Feedback + +Use haptics to confirm significant actions - destructive operations, toggles, long-press confirmations: + +```kotlin +@Composable +fun DeleteButton(onDelete: () -> Unit) { + val haptic = LocalHapticFeedback.current + Button( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onDelete() + } + ) { + Text("Delete") + } +} +``` + +Do not add haptics to every tap - reserve them for actions where physical confirmation improves UX. + +## Adaptive UI + +### Adaptive Navigation with NavigationSuiteScaffold + +`NavigationSuiteScaffold` automatically switches between bottom navigation bar, navigation rail, and navigation drawer based on `WindowSizeClass`. Do NOT manually branch on window size class -- the scaffold handles it. + +```kotlin +// app/AdaptiveAppNavigation.kt +@Composable +fun AdaptiveAppNavigation() { + var currentDestination by rememberSaveable { mutableStateOf(AppDestinations.HOME) } + + NavigationSuiteScaffold( + navigationSuiteItems = { + AppDestinations.entries.forEach { destination -> + item( + icon = { Icon(destination.icon, contentDescription = stringResource(destination.contentDescription)) }, + label = { Text(stringResource(destination.label)) }, + selected = destination == currentDestination, + onClick = { currentDestination = destination } + ) + } + } + ) { + when (currentDestination) { + AppDestinations.HOME -> HomeScreen() + AppDestinations.FAVORITES -> FavoritesScreen() + AppDestinations.SETTINGS -> SettingsScreen() + } + } +} + +enum class AppDestinations( + @StringRes val label: Int, + val icon: ImageVector, + @StringRes val contentDescription: Int +) { + HOME(R.string.home, Icons.Default.Home, R.string.home), + FAVORITES(R.string.favorites, Icons.Default.Favorite, R.string.favorites), + SETTINGS(R.string.settings, Icons.Default.Settings, R.string.settings), +} +``` + +To override the navigation type for specific cases (e.g., permanent drawer on expanded): + +```kotlin +val adaptiveInfo = currentWindowAdaptiveInfo() +val customNavSuiteType = with(adaptiveInfo) { + if (windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_EXPANDED_LOWER_BOUND)) { + NavigationSuiteType.NavigationDrawer + } else { + NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo(adaptiveInfo) + } +} + +NavigationSuiteScaffold( + navigationSuiteItems = { /* ... */ }, + layoutType = customNavSuiteType, +) { + // Content +} +``` + +### List-Detail Layout (NavigableListDetailPaneScaffold) + +Use `NavigableListDetailPaneScaffold` instead of raw `ListDetailPaneScaffold` -- it provides built-in navigation and predictive back handling. + +- On expanded screens: list and detail side by side +- On compact/medium: one pane at a time with navigation between them + +```kotlin +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun AuthSessionListDetailLayout( + viewModel: AuthSessionViewModel = hiltViewModel() +) { + val authEvents by viewModel.events.collectAsStateWithLifecycle() + val scaffoldNavigator = rememberListDetailPaneScaffoldNavigator() + + NavigableListDetailPaneScaffold( + navigator = scaffoldNavigator, + listPane = { + AnimatedPane { + LazyColumn { + items(authEvents) { event -> + AuthEventListItem( + event = event, + onClick = { + viewModel.selectEvent(event) + scaffoldNavigator.navigateTo( + ListDetailPaneScaffoldRole.Detail, + contentKey = event + ) + } + ) + } + } + } + }, + detailPane = { + AnimatedPane { + scaffoldNavigator.currentDestination?.contentKey?.let { event -> + AuthEventDetailScreen(event = event) + } + } + } + ) +} +``` + +For custom back behavior or more control, use `SupportingPaneScaffold` / `ListDetailPaneScaffold` directly with `ThreePaneScaffoldPredictiveBackHandler`: + +```kotlin +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun CustomListDetailLayout() { + val scaffoldNavigator = rememberListDetailPaneScaffoldNavigator() + + ThreePaneScaffoldPredictiveBackHandler( + navigator = scaffoldNavigator, + backBehavior = BackNavigationBehavior.PopUntilScaffoldValueChange + ) + + ListDetailPaneScaffold( + directive = scaffoldNavigator.scaffoldDirective, + scaffoldState = scaffoldNavigator.scaffoldState, + listPane = { + AnimatedPane { /* list content */ } + }, + detailPane = { + AnimatedPane { /* detail content */ } + } + ) +} +``` + +### Supporting Pane Layout (NavigableSupportingPaneScaffold) + +Use `NavigableSupportingPaneScaffold` to display a main content pane with a contextual supporting pane. The supporting pane shows related info (e.g., similar items, metadata, tools). + +- On expanded screens: main and supporting panes side by side +- On compact/medium: one pane at a time with navigation + +```kotlin +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun MovieDetailWithSuggestions(movie: Movie) { + val scaffoldNavigator = rememberSupportingPaneScaffoldNavigator() + val scope = rememberCoroutineScope() + + NavigableSupportingPaneScaffold( + navigator = scaffoldNavigator, + mainPane = { + AnimatedPane(modifier = Modifier.safeContentPadding()) { + MovieDetailContent( + movie = movie, + onShowSuggestions = { + scope.launch { + scaffoldNavigator.navigateTo(SupportingPaneScaffoldRole.Supporting) + } + }, + isSupportingPaneVisible = scaffoldNavigator.scaffoldValue[SupportingPaneScaffoldRole.Supporting] != PaneAdaptedValue.Hidden + ) + } + }, + supportingPane = { + AnimatedPane(modifier = Modifier.safeContentPadding()) { + Column { + if (scaffoldNavigator.scaffoldValue[SupportingPaneScaffoldRole.Supporting] == PaneAdaptedValue.Expanded) { + IconButton( + modifier = Modifier.align(Alignment.End).padding(16.dp), + onClick = { + scope.launch { + scaffoldNavigator.navigateBack(BackNavigationBehavior.PopUntilScaffoldValueChange) + } + } + ) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + } + SimilarMoviesList(movieId = movie.id) + } + } + } + ) +} +``` + +### Extracting Pane Composables + +Extract panes into separate composables using `ThreePaneScaffoldPaneScope` for reusability and testability: + +```kotlin +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun ThreePaneScaffoldPaneScope.MainPane( + showSupportingButton: Boolean, + onNavigateToSupporting: () -> Unit, + modifier: Modifier = Modifier +) { + AnimatedPane(modifier = modifier.safeContentPadding()) { + if (showSupportingButton) { + Button(onClick = onNavigateToSupporting) { + Text("Show details") + } + } + } +} +``` + +## Theming & Design System + +### Material 3 Theme + +```kotlin +// core/ui/theme/AppTheme.kt +@Composable +fun AppTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) + else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + // Status bar appearance is handled by enableEdgeToEdge() in MainActivity. + // Do NOT manually set statusBarColor or isAppearanceLightStatusBars here. + + MaterialTheme( + colorScheme = colorScheme, + typography = AppTypography, + shapes = AppShapes, + content = content + ) +} +``` + +### Custom Design Tokens + +```kotlin +// core/ui/theme/AppTypography.kt +val AppTypography = Typography( + displayLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W400, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp, + ), + displayMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W400, + fontSize = 45.sp, + lineHeight = 52.sp, + letterSpacing = 0.sp, + ), + displaySmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W400, + fontSize = 36.sp, + lineHeight = 44.sp, + letterSpacing = 0.sp, + ), + // Add other text styles... +) +``` + +### Component Shape Defaults + +Every M3 component reads its corner radius from `MaterialTheme.shapes` via a `*Defaults.shape` constant. Override at the **token** level (in `AppShapes`) to retheme everything consistently; override at the **component** level (`shape = ...`) only for genuine one-offs. Mixing radii across components on the same screen is the most common visual-polish bug. + +| Component | `*Defaults.shape` source | Token | Notes | +|---------------------------------------------------------------------------------|----------------------------------------------------------------------------|---------------------------------------------------------------------|------------------------------------------------------------------------------| +| `Button`, `FilledTonalButton`, `OutlinedButton`, `TextButton`, `ElevatedButton` | `ButtonDefaults.shape` | `shapes.full` (pill) | M3 Expressive ships pill-shaped buttons | +| `IconButton`, `FilledIconButton`, etc. | `IconButtonDefaults.*Shape` | `shapes.full` | Always circular at rest | +| `FloatingActionButton` | `FloatingActionButtonDefaults.shape` | `shapes.large` | 16dp corners | +| `ExtendedFloatingActionButton` | `FloatingActionButtonDefaults.extendedFabShape` | `shapes.large` | | +| `Card`, `OutlinedCard`, `ElevatedCard` | `CardDefaults.shape` / `outlinedShape` / `elevatedShape` | `shapes.medium` | 12dp corners; see [Card Variants](#card-variants-filled--outlined--elevated) | +| `AssistChip`, `FilterChip`, `InputChip`, `SuggestionChip` | `ChipDefaults.*Shape` | `shapes.small` | 8dp corners | +| `TextField`, `OutlinedTextField` | `TextFieldDefaults.shape` / `OutlinedTextFieldDefaults.shape` | top-only `extraSmall` (filled), `extraSmall` all corners (outlined) | Filled rounds **top corners only** | +| `AlertDialog`, `BasicAlertDialog` | `AlertDialogDefaults.shape` | `shapes.extraLarge` | 28dp corners | +| `ModalBottomSheet`, `BottomSheetScaffold` | `BottomSheetDefaults.ExpandedShape` | top-only `extraLarge` | Top corners only; bottom is flush | +| `ModalNavigationDrawer`, `DismissibleNavigationDrawer` | `DrawerDefaults.shape` | end-only `extraLarge` | Right edge corners only | +| `Snackbar` | `SnackbarDefaults.shape` | `shapes.extraSmall` | 4dp corners | +| `Menu` (`DropdownMenu`, `ExposedDropdownMenu`) | `MenuDefaults.shape` | `shapes.extraSmall` | | +| `Tooltip` (`PlainTooltip`, `RichTooltip`) | `TooltipDefaults.plainTooltipContainerShape` / `richTooltipContainerShape` | `shapes.extraSmall` (plain), `shapes.medium` (rich) | | +| `SearchBar`, `DockedSearchBar` | `SearchBarDefaults.inputFieldShape` | `shapes.full` | Pill | +| `Switch`, `RadioButton`, `Checkbox` | (handle-driven, no public shape token) | - | Don't override; baked into the component | +| `TopAppBar`, `BottomAppBar`, `NavigationBar`, `NavigationRail` | (none - full-bleed) | - | Never round these | + +#### Override at the token level, not per component + +```kotlin +val AppShapes = Shapes( + extraSmall = RoundedCornerShape(2.dp), + small = RoundedCornerShape(6.dp), + medium = RoundedCornerShape(10.dp), + large = RoundedCornerShape(14.dp), + extraLarge = RoundedCornerShape(24.dp), +) + +MaterialTheme(colorScheme = colorScheme, typography = AppTypography, shapes = AppShapes) { + // Card → 10dp, Dialog → 24dp, Snackbar → 2dp, etc. - automatic. +} +``` + +#### Per-component override is for one-offs only + +```kotlin +Card( + shape = MaterialTheme.shapes.large, +) { + HeroContent() +} +``` + +Reach for `shape = ...` only when a single instance must visually break the system rhythm - a hero card on a marketing screen, a custom-shaped CTA. Doing this across every `Card` / `Button` is the same as not having a shape system at all. + +#### Shape anti-patterns + +- **Don't `RoundedCornerShape(8.dp)` directly on a component.** Use `MaterialTheme.shapes.small` so a future token bump rethemes the whole app. +- **Don't round full-bleed bars.** `TopAppBar`, `BottomAppBar`, `NavigationBar`, `NavigationRail` are designed to sit edge-to-edge - corners on them clip incorrectly under gesture insets. +- **Don't round all four corners on `ModalBottomSheet` / drawers.** Use the `*ExpandedShape` / `*Shape` defaults; the asymmetric corners are load-bearing for the affordance. +- **Don't override `Switch` / `RadioButton` / `Checkbox` shape.** They're not derived from `MaterialTheme.shapes`; the visual is baked in by spec. + +### Component-Specific Themes + +```kotlin +// core/ui/components/ButtonStyles.kt +@Composable +fun PrimaryButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + text: @Composable () -> Unit, + icon: @Composable (() -> Unit)? = null +) { + Button( + onClick = onClick, + modifier = modifier, + enabled = enabled, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 12.dp) + ) { + if (icon != null) { + icon() + Spacer(modifier = Modifier.width(8.dp)) + } + text() + } +} +``` + +## Previews & Testing + +### Comprehensive Preview Setup + +```kotlin +// Preview annotations for different configurations +@Preview(name = "Light Mode") +@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +annotation class ThemePreviews + +@Preview(name = "Phone", device = Devices.PHONE) +@Preview(name = "Tablet", device = Devices.TABLET) +@Preview(name = "Desktop", device = Devices.DESKTOP) +annotation class DevicePreviews + +@Preview(name = "English", locale = "en") +@Preview(name = "Arabic", locale = "ar") +annotation class LocalePreviews +``` + +### Preview with Realistic Data + +```kotlin +// feature-auth/presentation/preview/LoginScreenPreview.kt +@ThemePreviews +@DevicePreviews +@Composable +fun LoginScreenPreview() { + AppTheme { + LoginScreen( + uiState = AuthUiState.LoginForm( + email = "user@example.com", + password = "password123", + isLoading = false + ), + onAction = { }, + onRegisterClick = { }, + onForgotPasswordClick = { }, + modifier = Modifier.fillMaxSize() + ) + } +} +``` + +### Preview Parameter Providers + +```kotlin +class AuthUiStatePreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + AuthUiState.Loading, + AuthUiState.LoginForm(), + AuthUiState.ForgotPasswordForm(email = "user@example.com"), + AuthUiState.Error( + message = "Invalid credentials", + canRetry = true + ) + ) +} + +@ThemePreviews +@Composable +fun LoginScreenAllStatesPreview( + @PreviewParameter(AuthUiStatePreviewParameterProvider::class) uiState: AuthUiState +) { + AppTheme { + LoginScreen( + uiState = uiState, + onAction = { }, + onRegisterClick = { }, + onForgotPasswordClick = { }, + modifier = Modifier.fillMaxSize() + ) + } +} +``` + +### Preview wrappers (Compose 1.11+) + +Use `@PreviewWrapperProvider` to inject the app theme or other ambient setup into previews. Implement [`PreviewWrapper`](https://developer.android.com/reference/kotlin/androidx/compose/ui/tooling/preview/PreviewWrapper) once and apply the provider on a `@Preview` or `@MultiPreview` annotation. + +```kotlin +class AppPreviewWrapper : PreviewWrapper { + @Composable + override fun Wrap(content: @Composable (() -> Unit)) { + AppTheme { content() } + } +} + +@PreviewWrapperProvider(AppPreviewWrapper::class) +@ThemePreviews +@Composable +private fun LoginButtonPreview() { + LoginButton(onClick = {}) +} +``` + +Apply `@PreviewWrapperProvider` on a `@MultiPreview` annotation to share the wrapper across all previews using it. + +### Stability Annotations: `@Immutable` vs `@Stable` + +Compose skips more work when the compiler can prove stability. Declare that contract with `@Immutable` / `@Stable`. + +**Required:** Import `@Immutable` / `@Stable` from `androidx.compose.runtime`. + +**Domain models:** Either add `androidx.compose.runtime` to the Gradle module that owns annotated domain types (Kotlin-only) or keep annotations on UI-layer models and cover domain types with the stability configuration in [`android-strictmode.md`](/references/android-strictmode.md#compose-stability-guardrails). + +```kotlin +// core/domain/build.gradle.kts +plugins { + alias(libs.plugins.app.android.library) // or app.jvm.library +} + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.runtime) // For @Immutable/@Stable +} +``` + +#### Use `@Immutable` when: + +**Contract:** every property is `val`, nested types are immutable, and instances never mutate after construction. + +```kotlin +// CORRECT: All properties are val and immutable +@Immutable +data class User( + val id: String, + val name: String, + val email: String, + val profileUrl: String? +) + +// CORRECT: Nested types are also immutable +@Immutable +data class AuthState( + val user: User?, // User is @Immutable + val isLoading: Boolean, + val error: String? +) + +// CORRECT: Sealed class with immutable children +@Immutable +sealed interface UiState { + data object Loading : UiState + data class Success(val data: String) : UiState + data class Error(val message: String) : UiState +} + +// WRONG: Contains mutable property +@Immutable // This is a lie! +data class MutableUser( + val id: String, + var name: String // var makes this mutable +) + +// WRONG: Contains mutable collection +@Immutable // This is a lie! +data class UserList( + val users: MutableList // Mutable collection +) +``` + +#### Use `@Stable` when: + +**Contract:** the type mutates, yet every change is observable (`mutableStateOf`, `StateFlow`, or `MutableState`). + +```kotlin +// CORRECT: Mutable but observable by Compose +@Stable +class AuthFormState { + var email by mutableStateOf("") + private set + + var password by mutableStateOf("") + private set + + var isLoading by mutableStateOf(false) + private set + + fun updateEmail(value: String) { + email = value + } + + fun updatePassword(value: String) { + password = value + } + + fun setLoading(loading: Boolean) { + isLoading = loading + } +} + +// CORRECT: Wraps StateFlow (observable) +@Stable +class SearchRepository @Inject constructor( + private val api: SearchApi +) { + private val _results = MutableStateFlow>(emptyList()) + val results: StateFlow> = _results.asStateFlow() + + suspend fun search(query: String) { + _results.value = api.search(query) + } +} + +// CORRECT: Interface can be marked @Stable if implementations guarantee stability +// See references/crashlytics.md → "Provider-Agnostic Interface" for full implementation +@Stable +interface CrashReporter { + fun log(message: String) + fun recordException(throwable: Throwable) +} + +// WRONG: Mutable and NOT observable by Compose +@Stable // This is a lie! +class BadFormState { + var email: String = "" // No mutableStateOf - Compose won't see changes! + var password: String = "" +} + +// WRONG: Truly immutable, should use @Immutable instead +@Stable // Use @Immutable instead +data class Config( + val apiUrl: String, + val timeout: Int +) +``` + +#### Decision Matrix + + +| Type Characteristics | Annotation | Example | +|--------------------------------|--------------|-----------------------------------------------------------------| +| All `val`, deeply immutable | `@Immutable` | `data class User(val id: String, val name: String)` | +| Mutable with `mutableStateOf` | `@Stable` | `var count by mutableStateOf(0)` | +| Mutable with `StateFlow` | `@Stable` | `val state: StateFlow` | +| Interface with stable contract | `@Stable` | `interface Repository` | +| Regular mutable class | **None** | Let Compose treat as unstable | +| `java.time` classes | **None** | `LocalDate`, `LocalTime`, `LocalDateTime` (Unstable by default) | + +> **Warning:** Standard Java time classes like `LocalDate`, `LocalTime`, and `LocalDateTime` are considered **unstable** by Compose. If you use them in your state, you must either wrap them in a stable class, map them to primitives (like epoch milliseconds), or configure them as stable via a stability configuration file. + + +#### Persistent Collections for Performance + +For collections held in state, prefer persistent collections to enable structural sharing, so unchanged items and structure are reused and unaffected composables are not unnecessarily invalidated. + +```kotlin +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList + +@Immutable +data class AuthEventUi( + val id: String, + val label: String +) + +@HiltViewModel +class AuthEventsViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle +) : ViewModel() { + private val _events = MutableStateFlow>(persistentListOf()) + val events: StateFlow> = _events.asStateFlow() + + fun onEventAdded(event: AuthEventUi) { + _events.update { it.add(event) } // Structural sharing - only new item allocated + } + + fun onEventsLoaded(events: List) { + _events.value = events.toPersistentList() + } +} +``` + +#### Key Rules + +1. **Don't guess**: Only add annotations when you have **proven performance issues** (use Compose Compiler reports) +2. **Don't lie**: Never annotate a type as `@Immutable` or `@Stable` unless it truly meets the contract +3. **Domain models**: Always `@Immutable` (from `core/domain`) +4. **UI models**: Usually `@Immutable` (display-only data) +5. **ViewModels**: Never annotate (already stable via Hilt/Compose integration) +6. **Repositories**: Mark interface `@Stable` if implementations guarantee stability +7. **Form state classes**: Use `@Stable` with `mutableStateOf` properties + +### Lazy Composition + +```kotlin +@Composable +fun AuthActivityListOptimized( + events: List, + onItemClick: (AuthEvent) -> Unit, + modifier: Modifier = Modifier +) { + LazyColumn( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items( + items = events, + key = { authEventKey(it) } // Essential for stable keys + ) { event -> + // Use remember for expensive computations + val title = remember(event) { + formatAuthEventTitle(event) + } + + AuthEventCard( + event = event, + title = title, + onClick = { onItemClick(event) } + ) + } + } +} +``` + +### State Hoisting for Performance + +```kotlin +@Composable +fun SearchableAuthActivity( + events: List, + modifier: Modifier = Modifier +) { + var searchQuery by remember { mutableStateOf("") } + + // Hoist expensive filtering + val filteredEvents by remember(events, searchQuery) { + derivedStateOf { + if (searchQuery.isEmpty()) { + events + } else { + events.filter { event -> + formatAuthEventTitle(event).contains(searchQuery, ignoreCase = true) + } + } + } + } + + Column(modifier = modifier) { + SearchBar( + query = searchQuery, + onQueryChange = { searchQuery = it } + ) + + AuthActivityList( + events = filteredEvents, + onItemClick = { /* ... */ }, + onLoadMore = { /* ... */ } + ) + } +} +``` + +### Hoistable Stable State Pattern + +For complex components, extract state into a hoistable stable state interface with a private implementation and a factory function. This allows callers to either provide their own state or let the component manage it. + +```kotlin +@Stable +interface VerticalScrollerState { + var scrollPosition: Int + var scrollRange: Int +} + +private class VerticalScrollerStateImpl( + scrollPosition: Int = 0, + scrollRange: Int = 0 +) : VerticalScrollerState { + private var _scrollPosition by mutableIntStateOf(scrollPosition) + + override var scrollRange by mutableIntStateOf(scrollRange) + + override var scrollPosition: Int + get() = _scrollPosition + set(value) { + _scrollPosition = value.coerceIn(0, scrollRange) + } +} + +// Factory function +fun VerticalScrollerState(): VerticalScrollerState = VerticalScrollerStateImpl() + +@Composable +fun VerticalScroller( + modifier: Modifier = Modifier, + state: VerticalScrollerState = remember { VerticalScrollerState() } +) { + val scrollPosition = state.scrollPosition + val scrollRange = state.scrollRange + + // Use state... +} +``` + +### `remember` and lambda routing + +**Start here:** Immutable stable parameter types; skip `remember`-wrapping lambdas until a trace ties recompositions to unstable captures. + +```kotlin +@Composable +fun AuthEventCard( + event: AuthEvent, // Make sure AuthEvent is @Immutable + onClick: (AuthEvent) -> Unit, + modifier: Modifier = Modifier +) { + // Direct lambda is fine - no premature optimization needed + Card( + onClick = { onClick(event) }, + modifier = modifier + ) { + // Card content... + } +} + +// Ensure your data model is immutable for Compose stability +@Immutable +data class AuthEvent( + val id: String, + val name: String, + val timestamp: Long +) +``` + +**Use when:** `onClick` identity churns and the composable is expensive (deep nesting or large lists). Hold the latest callback with `rememberUpdatedState` so the lambda body stays stable. + +```kotlin +@Composable +fun AuthEventCard( + event: AuthEvent, + onClick: (AuthEvent) -> Unit, + modifier: Modifier = Modifier +) { + // Keeps reference to latest onClick without recreating lambda + val currentOnClick by rememberUpdatedState(onClick) + + Card( + onClick = { currentOnClick(event) }, + modifier = modifier + ) { + // Card content... + } +} +``` + +**Use when:** `event` and `onClick` both churn and profiling shows allocation or recomposition cost tied to the handler lambda: + +```kotlin +@Composable +fun AuthEventCard( + event: AuthEvent, + onClick: (AuthEvent) -> Unit, + modifier: Modifier = Modifier +) { + // Creates one lambda per unique (event, onClick) pair + val onClickMemoized = remember(event, onClick) { + { onClick(event) } + } + + Card( + onClick = onClickMemoized, + modifier = modifier + ) { + // Card content... + } +} +``` + +Optimize only when profiling identifies a real recomposition or allocation hotspot. + +## Animation + +### State-Based Animations + +#### animate*AsState + +Animate a single property toward a target value. Restarts when the target changes. + +```kotlin +val size by animateDpAsState( + targetValue = if (isExpanded) 200.dp else 100.dp, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), + label = "size" +) + +Box(modifier = Modifier.size(size)) +``` + +Common variants: + +```kotlin +val color by animateColorAsState(targetValue = targetColor, label = "color") +val alpha by animateFloatAsState(targetValue = if (visible) 1f else 0f, label = "alpha") +val offset by animateIntOffsetAsState(targetValue = IntOffset(10, 20), label = "offset") +``` + +Always provide `label` - required for debugging in Layout Inspector. + +#### AnimatedVisibility + +Enter/exit animations for showing and hiding content. + +```kotlin +var visible by remember { mutableStateOf(true) } + +AnimatedVisibility( + visible = visible, + enter = slideInHorizontally(initialOffsetX = { -it }) + fadeIn(), + exit = slideOutHorizontally(targetOffsetX = { -it }) + fadeOut() +) { + Text("Animated content") +} +``` + +Built-in transitions (combine with `+`): + +- `slideInVertically` / `slideOutVertically` +- `slideInHorizontally` / `slideOutHorizontally` +- `expandVertically` / `shrinkVertically` +- `expandHorizontally` / `shrinkHorizontally` +- `fadeIn` / `fadeOut` +- `scaleIn` / `scaleOut` + +Per-transition animation specs: + +```kotlin +AnimatedVisibility( + visible = visible, + enter = slideInVertically( + initialOffsetY = { fullHeight -> fullHeight }, + animationSpec = spring() + ), + exit = slideOutVertically( + targetOffsetY = { fullHeight -> fullHeight }, + animationSpec = tween(durationMillis = 300) + ) +) { + Box(Modifier.fillMaxWidth().height(100.dp).background(MaterialTheme.colorScheme.primary)) +} +``` + +#### AnimatedContent + +Smooth transitions when swapping content based on state. + +```kotlin +var count by remember { mutableIntStateOf(0) } + +AnimatedContent( + targetState = count, + transitionSpec = { + if (targetState > initialState) { + slideInVertically { it } + fadeIn() togetherWith slideOutVertically { -it } + fadeOut() + } else { + slideInVertically { -it } + fadeIn() togetherWith slideOutVertically { it } + fadeOut() + }.using(SizeTransform(clip = false)) + }, + label = "counter" +) { target -> + Text("Count: $target", style = MaterialTheme.typography.headlineLarge) +} +``` + +`SizeTransform` animates container size during content changes. `togetherWith` pairs enter and exit transitions. + +#### Crossfade + +Fade-only content swap. Lightweight alternative to `AnimatedContent`. + +```kotlin +var showFirst by remember { mutableStateOf(true) } + +Crossfade(targetState = showFirst, label = "crossfade") { state -> + if (state) { + Text("First screen") + } else { + Text("Second screen") + } +} +``` + +### Coordinated Animations + +#### updateTransition + +Multiple animated values synchronized by a single state change. + +```kotlin +var expanded by remember { mutableStateOf(false) } +val transition = updateTransition(targetState = expanded, label = "expand") + +val size by transition.animateDp(label = "size") { if (it) 200.dp else 100.dp } +val color by transition.animateColor(label = "color") { + if (it) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant +} +val cornerRadius by transition.animateDp(label = "corner") { if (it) 16.dp else 8.dp } + +Box( + modifier = Modifier + .size(size) + .clip(RoundedCornerShape(cornerRadius)) + .background(color) + .clickable { expanded = !expanded } +) +``` + +#### rememberInfiniteTransition + +Looping animations for loading indicators and pulsing effects. + +```kotlin +val infiniteTransition = rememberInfiniteTransition(label = "loading") + +val alpha by infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(1000), + repeatMode = RepeatMode.Reverse + ), + label = "pulse" +) + +Box( + modifier = Modifier + .size(48.dp) + .alpha(alpha) + .background(MaterialTheme.colorScheme.primary, CircleShape) +) +``` + +Runs until composable leaves composition. + +### Imperative Animation Control + +#### Animatable + +Coroutine-based animation control. Use for gesture-driven animations and complex sequences. + +```kotlin +val offsetX = remember { Animatable(0f) } + +LaunchedEffect(shouldAnimate) { + if (shouldAnimate) { + offsetX.animateTo( + targetValue = 300f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy) + ) + } else { + offsetX.snapTo(0f) + } +} + +Box( + modifier = Modifier + .size(100.dp) + .graphicsLayer(translationX = offsetX.value) + .background(MaterialTheme.colorScheme.primary) +) +``` + +Gesture-driven: + +```kotlin +val offsetX = remember { Animatable(0f) } + +Box( + modifier = Modifier + .size(100.dp) + .offset { IntOffset(offsetX.value.roundToInt(), 0) } + .pointerInput(Unit) { + detectHorizontalDragGestures( + onDragEnd = { + scope.launch { + offsetX.animateTo(0f, animationSpec = spring()) + } + } + ) { _, dragAmount -> + scope.launch { + offsetX.snapTo(offsetX.value + dragAmount) + } + } + } + .background(MaterialTheme.colorScheme.primary) +) +``` + +### Animation Specifications + + +| Spec | Use Case | Parameters | +| ----------- | -------------------------------------------- | ----------------------------- | +| `spring` | Interactive feedback, natural motion | `dampingRatio`, `stiffness` | +| `tween` | Predictable timing, sequential animations | `durationMillis`, `easing` | +| `keyframes` | Complex choreography, frame-by-frame control | Values at specific timestamps | + + +```kotlin +// Spring - physics-based, no fixed duration (recommended for interactions) +spring( + dampingRatio = Spring.DampingRatioMediumBouncy, // NoBouncy(1f), LowBouncy(0.75f), MediumBouncy(0.5f), HighBouncy(0.2f) + stiffness = Spring.StiffnessLow // Low, Medium, MediumLow, High, VeryLow +) + +// Tween - time-based with easing +tween( + durationMillis = 300, + easing = FastOutSlowInEasing // also: LinearEasing, EaseInOutCubic, EaseInQuad, EaseOutQuad +) + +// Keyframes - exact values at timestamps +keyframes { + durationMillis = 300 + 0f at 0 using EaseInQuad + 0.5f at 150 using EaseOutQuad + 1f at 300 +} +``` + +Use `spring` for user-driven interactions. Use `tween` for choreographed sequences. + +### Material Design motion (duration and easing) + +Material motion uses consistent **durations** and **easing** so transitions feel intentional. Align `tween`/`keyframes` with these bands when you pick fixed timings (springs stay physics-driven). + +**Durations by interaction type** + +| Band | Duration | Typical use | +|--------|------------|---------------------------------------------| +| Micro | 50-100 ms | Ripples, small state toggles, hover | +| Short | 100-200 ms | Simple transitions, fades | +| Medium | 200-300 ms | Expand/collapse, bottom sheet motion | +| Long | 300-500 ms | Larger choreography, complex screen changes | + +Keep most UI transitions under about **400 ms** unless you are showing loading or long-form motion. Tablet/desktop can feel slightly slower; wearables often use shorter motion. See [Motion](https://m3.material.io/styles/motion/overview) for the full system. + +**Easing roles** (Material names map to cubic-bezier curves in design specs; in Compose use `FastOutSlowInEasing`, `LinearEasing`, or custom `CubicBezierEasing` as needed) + +| Role | Typical use | +|------------|------------------------------| +| Standard | Default enter/exit | +| Emphasized | Prominent transitions | +| Decelerate | Elements entering the screen | +| Accelerate | Elements leaving permanently | +| Sharp | Temporary exit and return | + +**Reduced motion:** Always respect `LocalReducedMotion` for enter/exit (see **Animation Anti-Patterns** below). + +### Layout Animations + +#### animateContentSize + +Automatic container size animation when content changes. + +```kotlin +var expanded by remember { mutableStateOf(false) } + +Column( + modifier = Modifier + .animateContentSize(animationSpec = spring()) + .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(8.dp)) + .clickable { expanded = !expanded } + .padding(16.dp) +) { + Text("Header", style = MaterialTheme.typography.titleMedium) + if (expanded) { + Spacer(modifier = Modifier.height(8.dp)) + Text("Expanded content that appears with a smooth size animation.") + } +} +``` + +#### animateItem in LazyLists + +Animates item insert, remove, and reorder. Requires stable keys. Replaces deprecated `animateItemPlacement()`. + +```kotlin +LazyColumn { + items(items, key = { it.id }) { item -> + ItemRow( + item = item, + modifier = Modifier.animateItem() + ) + } +} +``` + +### Shared Element Transitions + +Animate matching elements across screen transitions. + +```kotlin +SharedTransitionLayout { + AnimatedContent(targetState = showDetail, label = "shared") { isDetail -> + if (isDetail) { + DetailPane( + sharedTransitionScope = this@SharedTransitionLayout, + animatedVisibilityScope = this@AnimatedContent, + onBack = { showDetail = false } + ) + } else { + ListPane( + sharedTransitionScope = this@SharedTransitionLayout, + animatedVisibilityScope = this@AnimatedContent, + onItemClick = { showDetail = true } + ) + } + } +} +``` + +Both screens must use the same key: + +```kotlin +// In ListPane +Image( + painter = painterResource(R.drawable.photo), + contentDescription = "Product photo", + modifier = Modifier.sharedElement( + state = rememberSharedContentState(key = "image-${item.id}"), + animatedVisibilityScope = animatedVisibilityScope + ) +) + +// In DetailPane - same key +Image( + painter = painterResource(R.drawable.photo), + contentDescription = "Product photo", + modifier = Modifier.sharedElement( + state = rememberSharedContentState(key = "image-${item.id}"), + animatedVisibilityScope = animatedVisibilityScope + ) +) +``` + +- `sharedElement` - exact match (same content, animates position/size) +- `sharedBounds` - bounds morph (different content, animates container bounds) + +Navigation 3 shared elements: [android-navigation.md](/references/android-navigation.md). + +#### Visual debugging (Compose 1.11+) + +Wrap a `SharedTransitionLayout` with [`LookaheadAnimationVisualDebugging`](https://developer.android.com/reference/kotlin/androidx/compose/animation/package-summary#LookaheadAnimationVisualDebugging\(kotlin.Boolean,androidx.compose.ui.graphics.Color,androidx.compose.ui.graphics.Color,androidx.compose.ui.graphics.Color,kotlin.Boolean,kotlin.Function0\)) to overlay target bounds, animation trajectories, and unmatched / multi-match elements. Required: gate `isEnabled` behind `BuildConfig.DEBUG`. + +```kotlin +LookaheadAnimationVisualDebugging( + isEnabled = BuildConfig.DEBUG, + overlayColor = Color(0x4AE91E63), + multipleMatchesColor = Color.Green, + unmatchedElementColor = Color.Red, +) { + SharedTransitionLayout { + ... + } +} +``` + +### graphicsLayer for Animation Performance + +GPU-accelerated transforms that skip recomposition and relayout. + +```kotlin +// Good +val offset by animateFloatAsState(targetValue = 100f, label = "offset") +Box(modifier = Modifier.graphicsLayer(translationX = offset)) + +// Bad: relayout every frame +val offsetDp by animateDpAsState(targetValue = 100.dp, label = "offset") +Box(modifier = Modifier.offset(x = offsetDp)) +``` + +`graphicsLayer` properties: `translationX/Y`, `rotationX/Y/Z`, `scaleX/Y`, `alpha`. + +`Modifier.offset { }` (lambda version) is a middle ground - defers reads to layout phase, avoids recomposition but still triggers relayout. + +### Animation Anti-Patterns + +```kotlin +// Bad: instant visibility flip +if (visible) { Text("Content") } +// Good +AnimatedVisibility(visible = visible) { Text("Content") } + +// Bad: recreated every recomposition +val animatable = Animatable(0f) +// Good +val animatable = remember { Animatable(0f) } + +// Bad: state mutation during composition (infinite loop) +var position by remember { mutableFloatStateOf(0f) } +position += 10f +// Good: drive from a coroutine +LaunchedEffect(Unit) { + repeat(10) { position += 10f; delay(16) } +} + +// Bad: missing label +val size by animateDpAsState(targetValue = 100.dp) +// Good +val size by animateDpAsState(targetValue = 100.dp, label = "card_size") + +// Bad: ignores reduced-motion preference +AnimatedVisibility(visible = visible, enter = fadeIn() + slideInVertically()) { Content() } +// Good +val reducedMotion = LocalReducedMotion.current +AnimatedVisibility( + visible = visible, + enter = if (reducedMotion) EnterTransition.None else fadeIn() + slideInVertically() +) { Content() } +``` + +## Side Effects + +Use the correct effect for each scenario. Misuse causes stale state, resource leaks, or infinite recomposition loops. + +**Execution order:** Composition -> Side effects -> Layout -> Drawing. Effects only run after successful composition. + +### LaunchedEffect - Coroutines Scoped to Composition + +Coroutine tied to composable lifecycle. Cancelled when the key changes or composable leaves composition. + +```kotlin +@Composable +fun DataLoader(userId: String) { + var data by remember { mutableStateOf(null) } + + LaunchedEffect(userId) { + data = repository.loadUser(userId) + } + + data?.let { UserContent(it) } ?: LoadingScreen() +} +``` + +#### Key Selection Rules + +```kotlin +// Key = Unit: runs once when composable enters composition, never restarts +LaunchedEffect(Unit) { + analytics.logScreenView("home") +} + +// Key = specific value: restarts whenever the value changes +LaunchedEffect(userId) { + data = repository.loadUser(userId) +} + +// Multiple keys: restarts if ANY key changes +LaunchedEffect(userId, filterType) { + data = repository.loadFiltered(userId, filterType) +} +``` + +#### Cancellation and Cleanup + +When the key changes, the current coroutine is cancelled before the new one starts. Use `finally` for cleanup: + +```kotlin +LaunchedEffect(connectionId) { + val connection = openConnection(connectionId) + try { + connection.listen { message -> + processMessage(message) + } + } finally { + connection.close() + } +} +``` + +### DisposableEffect - Resource Cleanup + +Use for listeners, registrations, and resources that need explicit cleanup via `onDispose`. + +```kotlin +@Composable +fun ScreenWithLifecycle(onResume: () -> Unit, onPause: () -> Unit) { + val lifecycle = LocalLifecycleOwner.current.lifecycle + + DisposableEffect(lifecycle) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> onResume() + Lifecycle.Event.ON_PAUSE -> onPause() + else -> Unit + } + } + lifecycle.addObserver(observer) + + onDispose { + lifecycle.removeObserver(observer) + } + } +} +``` + +Use `DisposableEffect` instead of `LaunchedEffect` when cleanup is not coroutine-based (unregistering listeners, receivers, or callbacks). + +```kotlin +@Composable +fun BroadcastListener(context: Context, action: String, onReceive: (Intent) -> Unit) { + DisposableEffect(action) { + val receiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context, intent: Intent) { + onReceive(intent) + } + } + val filter = IntentFilter(action) + context.registerReceiver(receiver, filter) + + onDispose { + context.unregisterReceiver(receiver) + } + } +} +``` + +### SideEffect - After Every Composition + +Runs after *every* successful composition. No keys, no cleanup. Use sparingly. + +```kotlin +@Composable +fun TrackScreenView(screenName: String) { + SideEffect { + analytics.logScreenView(screenName) + } +} +``` + +Only for: analytics logging, synchronizing with non-Compose UI, one-way state sync without cleanup. +Never for: resource allocation (`DisposableEffect`) or coroutines (`LaunchedEffect`). + +### rememberCoroutineScope - Launching from Event Handlers + +Coroutine scope tied to composable lifecycle. Use for launching coroutines from callbacks (clicks, gestures) - not for state-driven work (use `LaunchedEffect` instead). + +```kotlin +@Composable +fun SnackbarDemo(snackbarHostState: SnackbarHostState) { + val scope = rememberCoroutineScope() + + Button( + onClick = { + scope.launch { + snackbarHostState.showSnackbar("Action completed") + } + } + ) { + Text("Show Snackbar") + } +} +``` + +```kotlin +// Bad: blocks UI thread +Button(onClick = { + runBlocking { fetchData() } +}) { Text("Fetch") } + +// Good +val scope = rememberCoroutineScope() +Button(onClick = { + scope.launch { fetchData() } +}) { Text("Fetch") } +``` + +### rememberUpdatedState - Capturing Latest Values + +Keeps a reference to the latest value without restarting a long-running effect. + +```kotlin +@Composable +fun TimedMessage( + message: String, + onTimeout: () -> Unit, + timeoutMillis: Long = 5000L +) { + val currentOnTimeout by rememberUpdatedState(onTimeout) + + LaunchedEffect(timeoutMillis) { + delay(timeoutMillis) + currentOnTimeout() + } +} +``` + +Without it, changing `onTimeout` either restarts the effect (if used as key) or calls a stale callback (if captured directly): + +```kotlin +// Bad: restarts on every lambda identity change +LaunchedEffect(onTimeout) { + delay(5000) + onTimeout() +} + +// Bad: captures stale onTimeout +LaunchedEffect(Unit) { + delay(5000) + onTimeout() +} +``` + +### produceState - Converting External State to Compose State + +Converts imperative sources (callbacks, flows, suspend functions) into Compose `State`. Combines `remember` + `LaunchedEffect` + state creation. + +```kotlin +@Composable +fun NetworkStatus(): State { + val context = LocalContext.current + + return produceState(initialValue = true) { + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { value = true } + override fun onLost(network: Network) { value = false } + } + + val connectivityManager = context.getSystemService() + connectivityManager?.registerDefaultNetworkCallback(callback) + + awaitDispose { + connectivityManager?.unregisterNetworkCallback(callback) + } + } +} + +@Composable +fun AppContent() { + val isOnline by NetworkStatus() + + if (!isOnline) { + OfflineBanner() + } +} +``` + +Use `awaitDispose` for cleanup (equivalent to `onDispose` in `DisposableEffect`). + +### LifecycleResumeEffect - onResume / onPause + +Runs code when the `LifecycleOwner` reaches `RESUMED` state. Cleanup runs on `onPause` or when the composable leaves composition. Use for work that must only be active while the screen is visible and interactive. + +```kotlin +@Composable +fun CameraPreview(cameraController: CameraController) { + LifecycleResumeEffect(cameraController) { + cameraController.startPreview() + + onPauseOrDispose { + cameraController.stopPreview() + } + } + + // camera UI... +} +``` + +Common use cases: +- Start/stop camera or media playback +- Resume/pause sensor updates +- Register/unregister push notification listeners +- Analytics screen-view tracking (fires on return from background) + +```kotlin +@Composable +fun ScreenAnalytics(screenName: String) { + LifecycleResumeEffect(screenName) { + analytics.logScreenView(screenName) + + onPauseOrDispose { } + } +} +``` + +**Rule:** `onPauseOrDispose` block is mandatory - compiler enforces it. + +### LifecycleStartEffect - onStart / onStop + +Same pattern as `LifecycleResumeEffect` but maps to `STARTED` state. Runs on `onStart`, cleans up on `onStop` or dispose. + +```kotlin +@Composable +fun LocationTracker(locationManager: LocationManager) { + LifecycleStartEffect(Unit) { + val listener = LocationListener { location -> updateMap(location) } + locationManager.requestLocationUpdates( + LocationManager.GPS_PROVIDER, 5000L, 10f, listener + ) + + onStopOrDispose { + locationManager.removeUpdates(listener) + } + } +} +``` + +#### Lifecycle effect routing + +| Effect | Active During | Use For | +|--------|--------------|---------| +| `LifecycleResumeEffect` | `onResume` to `onPause` | Camera, media playback, interactive features | +| `LifecycleStartEffect` | `onStart` to `onStop` | Location, sensors, background-visible work | +| `DisposableEffect` | Composition to disposal | Composition-scoped setup with no lifecycle callbacks | + +**Required:** Use `LifecycleResumeEffect` / `LifecycleStartEffect` instead of hand-rolling `DisposableEffect` + `LifecycleEventObserver` on `LocalLifecycleOwner`; they match lifecycle edges with less code and mandatory cleanup hooks. + +```kotlin +// BAD: Manual lifecycle observer boilerplate +DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> startCamera() + Lifecycle.Event.ON_PAUSE -> stopCamera() + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } +} + +// GOOD: Dedicated lifecycle effect +LifecycleResumeEffect(Unit) { + startCamera() + onPauseOrDispose { stopCamera() } +} +``` + +### Effect Decision Guide + + +| Scenario | Effect | Why | +| --------------------------------------------------- | ----------------------------- | --------------------------------- | +| Load data when key changes | `LaunchedEffect(key)` | Coroutine restarts on key change | +| One-time setup (analytics, logging) | `LaunchedEffect(Unit)` | Runs once, no restart needed | +| Register/unregister listener | `DisposableEffect(key)` | Needs deterministic cleanup | +| Work active only while resumed (camera, media) | `LifecycleResumeEffect` | Pauses on `onPause`, resumes on `onResume` | +| Work active while started (location, sensors) | `LifecycleStartEffect` | Stops on `onStop`, starts on `onStart` | +| Sync with external system after every recomposition | `SideEffect` | No keys, no cleanup | +| Launch coroutine from click handler | `rememberCoroutineScope` | Event-driven, not state-driven | +| Keep latest callback in long-running effect | `rememberUpdatedState` | Avoid restart or stale capture | +| Convert imperative source to Compose state | `produceState` | Bridges callback/suspend to State | + + +### Side Effect Anti-Patterns + +```kotlin +// Bad: wrong key - never re-runs on userId change +@Composable +fun UserProfile(userId: String) { + var user by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + user = repository.loadUser(userId) + } +} +// Good +LaunchedEffect(userId) { + user = repository.loadUser(userId) +} + +// Bad: missing onDispose +DisposableEffect(Unit) { + val listener = Listener() + manager.register(listener) +} +// Good +DisposableEffect(Unit) { + val listener = Listener() + manager.register(listener) + onDispose { manager.unregister(listener) } +} + +// Bad: stale capture +var count by remember { mutableIntStateOf(0) } +LaunchedEffect(Unit) { + delay(1000) + println(count) +} +// Good +LaunchedEffect(Unit) { + snapshotFlow { count }.collect { println("Count: $it") } +} + +// Bad: navigation during composition +if (isLoggedIn) { + navigator.navigateToHome() +} +// Good +LaunchedEffect(isLoggedIn) { + if (isLoggedIn) navigator.navigateToHome() +} +``` + +## Modifiers + +Modifiers apply layout, drawing, gesture, and accessibility behavior. **Order matters** - modifiers apply left-to-right in the chain. + +### Modifier Chain Ordering + +```kotlin +// Red background THEN padding THEN size - red fills behind padding +Box( + Modifier + .background(Color.Red) + .padding(16.dp) + .size(100.dp) +) + +// Size THEN padding THEN red background - different result +Box( + Modifier + .size(100.dp) + .padding(16.dp) + .background(Color.Red) +) +``` + +**Rule:** Order from outer (layout/sizing) to inner (styling/interaction): + +1. Size constraints (`size`, `fillMaxWidth`, `sizeIn`) +2. Padding / margin (`padding`) +3. Drawing (`background`, `border`, `clip`) +4. Interaction (`clickable`, `pointerInput`) + +### Common Modifier Patterns + +#### Sizing + +```kotlin +Box(Modifier.size(100.dp)) +Box(Modifier.size(width = 200.dp, height = 100.dp)) +Box(Modifier.fillMaxWidth(0.8f)) // 80% of parent width +Box(Modifier.fillMaxSize()) +Box(Modifier.sizeIn(minWidth = 48.dp, minHeight = 48.dp)) // minimum touch target +``` + +#### Background and Border + +```kotlin +// Apply clip before background for shape consistency +Box( + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surface) + .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp)) + .padding(16.dp) +) +``` + +#### Clipping + +```kotlin +// Clip content to shape - apply BEFORE background +Box( + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.primaryContainer) +) { + AsyncImage(model = url, contentDescription = "Photo") +} +``` + +#### Drawing + +Use `drawWithCache` to optimize drawing operations by persisting objects across draw calls. The cache is re-created only when the drawing area size changes or any state objects read within the cache block change. + +```kotlin +Box( + Modifier + .drawWithCache { + // Objects created here are cached + val brush = Brush.linearGradient(listOf(Color.Red, Color.Blue)) + onDrawBehind { + // Drawing logic using cached objects + drawRect(brush) + } + } +) +``` + +### Clickable and CombinedClickable + +```kotlin +// Basic clickable with Material ripple +Box( + Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { onItemClick() } + .padding(16.dp) +) + +// Long press + double click + click +Box( + Modifier + .clip(RoundedCornerShape(8.dp)) + .combinedClickable( + onClick = { onItemClick() }, + onLongClick = { onLongPress() }, + onDoubleClick = { onDoubleTap() } + ) + .padding(16.dp) +) +``` + +Place `clickable` AFTER `clip` (for ripple bounds) but BEFORE `padding` (for larger touch target). + +### Conditional Modifiers + +Use `Modifier.then()` for conditional chaining: + +```kotlin +// Good +Box( + Modifier + .fillMaxWidth() + .then(if (isSelected) Modifier.background(selectedColor) else Modifier) + .padding(16.dp) +) + +// Bad +val mod = if (isSelected) Modifier.background(selectedColor) else Modifier +Box(mod.padding(16.dp)) +``` + +### Custom Modifiers with Modifier.Node + +`Modifier.Node` is the recommended API for custom modifiers. `Modifier.composed` is deprecated. + +```kotlin +// Modifier.Node API (recommended) +private class HighlightNode(var color: Color) : DrawModifierNode, Modifier.Node() { + override fun ContentDrawScope.draw() { + drawContent() + drawRect(color = color, alpha = 0.1f) + } +} + +private data class HighlightElement(val color: Color) : ModifierNodeElement() { + override fun create() = HighlightNode(color) + override fun update(node: HighlightNode) { node.color = color } +} + +fun Modifier.highlight(color: Color) = this then HighlightElement(color) + +// Usage +Box(Modifier.highlight(MaterialTheme.colorScheme.primary)) +``` + +```kotlin +// Deprecated: Modifier.composed - do NOT use for new code +fun Modifier.oldStyleModifier() = composed { + val state = remember { mutableStateOf(false) } + this.background(if (state.value) Color.Blue else Color.Gray) +} +``` + +### Layout vs Drawing vs Pointer Input + + +| Category | When It Runs | Use For | +| ------------- | -------------------------- | ------------------------------------------------------- | +| Layout | Measurement/placement pass | `size`, `padding`, `offset`, custom `LayoutModifier` | +| Drawing | Draw pass (after layout) | `background`, `border`, `drawBehind`, `drawWithContent` | +| Pointer Input | Input event handling | `clickable`, `pointerInput`, `draggable` | + + +```kotlin +// Custom drawing - runs in draw phase, no recomposition +fun Modifier.debugBorder() = drawBehind { + drawRect(color = Color.Red, style = Stroke(width = 2f)) +} + +// Custom gesture - runs in pointer input phase +fun Modifier.onSwipeRight(onSwipe: () -> Unit) = pointerInput(Unit) { + detectHorizontalDragGestures { _, dragAmount -> + if (dragAmount > 50f) onSwipe() + } +} +``` + +### Trackpad and mouse input (Compose 1.11+) + +Required: validate every gesture detector against trackpad, mouse, and stylus, not only touch. + +Behavior changes in Compose 1.11: + +- Basic trackpad events report `PointerType.Mouse` (previously `PointerType.Touch`). +- Click-and-drag on a trackpad selects in text fields; it no longer scrolls. +- `Modifier.scrollable` and `Modifier.transformable` recognise platform two-finger swipe and pinch on API 34+. + +Forbidden: branching gesture logic on `PointerType.Touch` to gate trackpad behaviour. + +Test trackpad gestures with `performTrackpadInput` - see [testing.md](/references/testing.md). + +### graphicsLayer - GPU Transforms + +Applies transforms at the GPU level - no recomposition, no relayout. Use for animations that should skip composition/layout work. + +```kotlin +Box( + Modifier.graphicsLayer( + scaleX = 1.2f, + scaleY = 1.2f, + rotationZ = 45f, + alpha = 0.8f, + translationX = 10f + ) +) +``` + +See [Animation > graphicsLayer](#graphicslayer-for-animation-performance) for animation-specific usage. + +### Semantics and TestTag + +```kotlin +// Accessibility semantics +Box( + Modifier + .semantics { + contentDescription = "User avatar" + role = Role.Image + } + .size(48.dp) +) + +// Test tag for UI tests +Box(Modifier.testTag("submit_button")) + +// In tests: +composeTestRule.onNodeWithTag("submit_button").performClick() +``` + +Comprehensive accessibility patterns: [android-accessibility.md](/references/android-accessibility.md). + +### Always Accept Modifier Parameter + +Every public composable must accept `modifier: Modifier = Modifier`. + +```kotlin +// Good +@Composable +fun UserCard( + user: User, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card(modifier = modifier.clickable { onClick() }) { + Text(user.name) + } +} + +// Bad +@Composable +fun UserCard(user: User, onClick: () -> Unit) { + Card { Text(user.name) } +} +``` + +### Modifier Anti-Patterns + +```kotlin +// Bad: padding before size +Modifier.padding(16.dp).size(100.dp) +// Good +Modifier.size(100.dp).padding(16.dp) + +// Bad: clickable before clip (ripple overflows) +Modifier.clickable { }.clip(RoundedCornerShape(8.dp)) +// Good +Modifier.clip(RoundedCornerShape(8.dp)).clickable { } + +// Bad: background before clip +Modifier.background(Color.Blue).clip(RoundedCornerShape(8.dp)) +// Good +Modifier.clip(RoundedCornerShape(8.dp)).background(Color.Blue) + +// Bad: hardcoded modifier +@Composable +fun BadCard() { + Box(Modifier.padding(16.dp).background(Color.Blue)) { } +} +// Good +@Composable +fun GoodCard(modifier: Modifier = Modifier) { + Box(modifier.padding(16.dp).background(Color.Blue)) { } +} +``` + +## CompositionLocal + +Implicit data passing down the composition tree without threading through every parameter. Use for configuration-like values (theme, locale, density), not for general dependency injection. + +### compositionLocalOf vs staticCompositionLocalOf + +```kotlin +// compositionLocalOf: use when value changes and consumers need updates +val LocalUserPreferences = compositionLocalOf { + error("UserPreferences not provided") +} + +// staticCompositionLocalOf: use when value rarely/never changes (no change tracking overhead) +val LocalAnalytics = staticCompositionLocalOf { + error("Analytics not provided") +} + +// compositionLocalWithComputedDefaultOf: computed default based on other locals +val LocalContentAlpha = compositionLocalWithComputedDefaultOf { 1f } +``` + +| Type | Recomposition Behavior | Use When | +|------|----------------------|----------| +| `compositionLocalOf` | All consumers recompose on value change | Theme colors, user preferences, frequently changing config | +| `staticCompositionLocalOf` | Only direct readers update | Analytics, loggers, app version, static config | +| `compositionLocalWithComputedDefaultOf` | Computed default from other locals | Derived configuration values | + +### Providing and Reading Values + +```kotlin +// Provide values +CompositionLocalProvider( + LocalUserPreferences provides userPrefs, + LocalAnalytics provides analytics +) { + AppContent() +} + +// Read values +@Composable +fun UserAvatar() { + val prefs = LocalUserPreferences.current + // use prefs... +} +``` + +Values are scoped to descendants. Inner providers override outer ones. + +### Built-In CompositionLocals + +| Local | Type | Access Pattern | +|-----------------------------------|-------------------------------|--------------------------------------------| +| `LocalContext` | `Context` | `val context = LocalContext.current` | +| `LocalConfiguration` | `Configuration` | Screen size, orientation, density | +| `LocalDensity` | `Density` | dp/px conversions | +| `LocalLayoutDirection` | `LayoutDirection` | LTR/RTL | +| `LocalLifecycleOwner` | `LifecycleOwner` | Activity/Fragment lifecycle | +| `LocalView` | `View` | Underlying Android View | +| `LocalSoftwareKeyboardController` | `SoftwareKeyboardController?` | Control software keyboard (hide/show) | +| `LocalFocusManager` | `FocusManager` | Control focus within Compose (clear focus) | +| `LocalClipboard` | `Clipboard` | Platform clipboard service (copy/paste) | +| `LocalUriHandler` | `UriHandler` | Open URIs (e.g., in a browser) | +| `LocalHapticFeedback` | `HapticFeedback` | Provide haptic feedback (vibrations) | + +### CompositionLocal routing + +**Use when:** +- Many descendants need the same read-only value. +- The value is configuration-shaped (theme, locale, feature flags). +- Parameter drilling would cross five or more layers. + +**Forbidden:** +- Data only one or two levels deep — pass parameters. +- Rapidly changing values that need explicit ownership — model them in state or a `ViewModel`. +- App-wide service graphs — wire those through Hilt, not `CompositionLocal`. + +### CompositionLocal Anti-Patterns + +```kotlin +// Bad: generic DI container +val LocalEverything = compositionLocalOf { AppContainer() } + +// Bad: MutableState inside CompositionLocal +val LocalCounter = compositionLocalOf { mutableStateOf(0) } + +// Good: provide the value, hoist the state +val LocalCount = compositionLocalOf { 0 } +@Composable +fun Parent() { + var count by remember { mutableIntStateOf(0) } + CompositionLocalProvider(LocalCount provides count) { + Child() + } +} +``` + +## Lists & Scrolling + +### Paging 3 + +Paging 3 is the standard for loading large datasets in chunks. + +#### Five Critical Rules +1. **Never embed `PagingData` in `UiState`**: `PagingData` is a self-contained stream. Expose it as a separate `Flow>`. +2. **No new `Pager` per recomposition**: Create the `Pager` once in the ViewModel. +3. **Always use `cachedIn(viewModelScope)`**: Prevents duplicate network requests and crashes on configuration changes. +4. **Stable keys**: Always provide a stable `key` in `items()` using the item's unique ID. +5. **Dynamic queries**: Use `flatMapLatest` for parameter changes (e.g., search query), not naive `combine`. + +#### ViewModel Pattern +```kotlin +@HiltViewModel +class SearchViewModel @Inject constructor( + private val repository: SearchRepository +) : ViewModel() { + + private val _query = MutableStateFlow("") + val query = _query.asStateFlow() + + // Separate Flow for PagingData, distinct from regular UiState + @OptIn(ExperimentalCoroutinesApi::class) + val searchResults: Flow> = _query + .debounce(300) + .distinctUntilChanged() + .flatMapLatest { q -> + Pager( + config = PagingConfig(pageSize = 20, enablePlaceholders = false), + pagingSourceFactory = { repository.search(q) } + ).flow + } + .cachedIn(viewModelScope) // CRITICAL + + fun setQuery(newQuery: String) { + _query.value = newQuery + } +} +``` + +#### Compose UI Pattern +```kotlin +@Composable +fun SearchScreen(viewModel: SearchViewModel = hiltViewModel()) { + val query by viewModel.query.collectAsStateWithLifecycle() + val searchResults = viewModel.searchResults.collectAsLazyPagingItems() + + Column { + SearchBar(query = query, onQueryChange = viewModel::setQuery) + + LazyColumn { + items( + count = searchResults.itemCount, + key = searchResults.itemKey { it.id }, // CRITICAL: Stable key + contentType = searchResults.itemContentType { "search_result" } + ) { index -> + val item = searchResults[index] + if (item != null) { + SearchResultRow(item) + } else { + SearchResultPlaceholder() + } + } + + // LoadState handling + when (val appendState = searchResults.loadState.append) { + is LoadState.Loading -> item { LoadingSpinner() } + is LoadState.Error -> item { ErrorRow(appendState.error) } + is LoadState.NotLoading -> Unit + } + } + } +} +``` + +**Anti-pattern:** Never call `searchResults.refresh()` directly in the composable body (it will loop infinitely). Call it only in event handlers (e.g., `PullToRefresh` or a retry button). + +#### Offline-first paging and RemoteMediator + +Use `RemoteMediator` when the list reads a Room 3 `PagingSource` and each page is fetched from a remote API and written into Room inside `load`. + +Wire `Pager(config = ..., remoteMediator = ..., pagingSourceFactory = { dao.pagingSource() }).flow`, then `cachedIn(viewModelScope)` using the same ViewModel rules as server-only paging. Keep entities and keys only in Room; the UI still collects one `Flow>`. + +**Required:** +- Implement `initialize()`. Return `InitializeAction.LAUNCH_INITIAL_REFRESH` when the first open must hit the network before trusting cached rows. Return `InitializeAction.SKIP_INITIAL_REFRESH` when warm Room data is valid until scroll-driven loads or explicit invalidation. Match return value to product rules for cold start vs cache. Read [RemoteMediator](https://developer.android.com/topic/libraries/architecture/paging/v3-network-db) for `InitializeAction` and `load`. +- Store remote page keys in Room (for example a `RemoteKeys` entity with `nextKey`, `prevKey`, and a query or feed id column). Read keys at the start of `load`, persist updated keys in the same transaction as entity inserts for that page. +- After backend writes or sync completion that change list contents, invalidate the backing `PagingSource` or trigger mediator refresh so `Pager` reloads. + +Add `androidx.room3:room3-paging` and `@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)` on the DAO or `@Database` per [Room 3 release notes](https://developer.android.com/jetpack/androidx/releases/room3). Conflict handling, backoff, and non-paged sync: [android-data-sync.md](/references/android-data-sync.md). + +**Forbidden:** +- Returning `LAUNCH_INITIAL_REFRESH` when `SKIP_INITIAL_REFRESH` matches the warm-cache entry rule (forces avoidable network on every launch that already has Room pages). +- Feeding the `Pager` from in-memory caches while the `PagingSource` reads Room (split sources of truth for the same list). + +### Flow Layouts + +Use `FlowRow` and `FlowColumn` for wrapping content (like chips or tags) when it exceeds the available space. + +```kotlin +FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + maxItemsInEachRow = 3 // Optional: force wrapping after N items +) { + tags.forEach { tag -> + FilterChip( + selected = tag.isSelected, + onClick = { onTagClick(tag) }, + label = { Text(tag.name) } + ) + } +} +``` + +### contentType for Recycling Optimization + +When rendering different item types, `contentType` enables layout reuse between items of the same type: + +```kotlin +sealed class FeedItem { + data class Header(val title: String) : FeedItem() + data class Post(val id: String, val content: String) : FeedItem() + data class Ad(val id: String) : FeedItem() +} + +LazyColumn { + items( + items = feedItems, + key = { item -> + when (item) { + is FeedItem.Header -> "header-${item.title}" + is FeedItem.Post -> item.id + is FeedItem.Ad -> item.id + } + }, + contentType = { item -> + when (item) { + is FeedItem.Header -> "header" + is FeedItem.Post -> "post" + is FeedItem.Ad -> "ad" + } + } + ) { item -> + when (item) { + is FeedItem.Header -> HeaderRow(item) + is FeedItem.Post -> PostCard(item) + is FeedItem.Ad -> AdBanner(item) + } + } +} +``` + +Without `contentType`, all items share one pool. With it, items reuse layouts efficiently. If two headers could share the same title, give `Header` a stable unique id and use that in the `key` lambda instead of `title`. + +### LazyListState - Programmatic Scrolling + +`LazyColumn` and `LazyRow` take `state: LazyListState = rememberLazyListState()` by default. **If you do not need a reference to the list's scroll state, omit `state` entirely** - the default remembers scroll for you inside the lazy list. + +**Hoist `LazyListState` explicitly** (create `val listState = rememberLazyListState()` and pass `state = listState`) only when something in **your** composable tree must call into that same instance - for example: + +- `animateScrollToItem` / `scrollToItem` (FAB, deep link, 'jump to') +- Reading `firstVisibleItemIndex`, `firstVisibleItemScrollOffset`, or `layoutInfo` (progress indicators, scroll-aware headers) +- `derivedStateOf { … }` tied to scroll (e.g. show/hide scroll-to-top) +- `Modifier.nestedScroll` or other APIs that need the list's `NestedScrollConnection` / state + +If none of that applies, use a plain `LazyColumn { … }` with no `state` parameter. + +**Do not** copy `firstVisibleItemIndex`, `firstVisibleItemScrollOffset`, or similar into the ViewModel's `StateFlow` for a normal feed - those values change constantly and will spam state updates without business value. + +Hoist or persist scroll only when there is a **clear requirement**: e.g. **process death** / configuration recovery (persist minimal scroll hints via `SavedStateHandle` or `rememberSaveable` when you own the saver), or a spec that ties list position to something outside the composable. Otherwise treat scroll position as **UI-local**, like other transient layout state. + +```kotlin +val listState = rememberLazyListState() +val scope = rememberCoroutineScope() + +LazyColumn(state = listState) { + items(items, key = { it.id }) { item -> ItemRow(item) } +} + +// Scroll to item +Button(onClick = { scope.launch { listState.animateScrollToItem(0) } }) { + Text("Scroll to top") +} + +// Read scroll position +val firstVisibleIndex = listState.firstVisibleItemIndex +val firstVisibleOffset = listState.firstVisibleItemScrollOffset +``` + +Use `derivedStateOf` for scroll-dependent UI to avoid recomposing the entire list: + +```kotlin +val showScrollToTop by remember { + derivedStateOf { listState.firstVisibleItemIndex > 5 } +} + +if (showScrollToTop) { + FloatingActionButton(onClick = { scope.launch { listState.animateScrollToItem(0) } }) { + Icon(painterResource(R.drawable.ic_arrow_up), "Scroll to top") + } +} +``` + +### Sticky Headers + +```kotlin +LazyColumn { + groupedItems.forEach { (category, items) -> + stickyHeader(key = "header-$category") { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = category, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + } + items(items, key = { it.id }) { item -> + ItemRow(item) + } + } +} +``` + +### Grids + +```kotlin +// Fixed columns +LazyVerticalGrid(columns = GridCells.Fixed(3)) { + items(items, key = { it.id }) { GridItem(it) } +} + +// Adaptive - fills available space with min column width +LazyVerticalGrid(columns = GridCells.Adaptive(minSize = 120.dp)) { + items(items, key = { it.id }) { GridItem(it) } +} +``` + +Use `GridCells.Adaptive` for responsive layouts. + +### Staggered Grid + +Pinterest-style layout with variable heights: + +```kotlin +LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Fixed(2), + contentPadding = PaddingValues(16.dp), + verticalItemSpacing = 8.dp, + horizontalArrangement = Arrangement.spacedBy(8.dp) +) { + items(images, key = { it.id }) { image -> + AsyncImage( + model = image.url, + contentDescription = image.description, + modifier = Modifier.fillMaxWidth() + ) + } +} +``` + +### Pager + +```kotlin +val pagerState = rememberPagerState(pageCount = { pages.size }) + +HorizontalPager(state = pagerState) { page -> + PageContent(pages[page]) +} + +// Programmatic scroll +val scope = rememberCoroutineScope() +Button(onClick = { scope.launch { pagerState.animateScrollToPage(2) } }) { + Text("Go to page 3") +} +``` + +`VerticalPager` works the same for vertical swiping. Replaces deprecated `accompanist-pager`. + +### Nested Scrolling Pitfalls + +```kotlin +// Bad: nested same-axis scrollables fight +LazyColumn { + item { + Column(Modifier.verticalScroll(rememberScrollState())) { + Text("Double scrollable!") + } + } +} + +// OK: nested LazyRow in LazyColumn (different axes) +LazyColumn { + item { + LazyRow { + items(horizontalItems) { HorizontalCard(it) } + } + } +} + +// For complex same-axis nesting, use nestedScroll: +val nestedScrollConnection = remember { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + return Offset.Zero // custom handling + } + } +} +LazyColumn(Modifier.nestedScroll(nestedScrollConnection)) { + items(100) { Text("Item $it") } +} +``` + +### Lists Rules + +- Always provide stable, unique `key` for mutable lists (IDs, not indices) +- Use `contentType` for multi-type lists +- Use `Column`/`Row` for small fixed lists (< 10 items) - `LazyColumn` is overkill +- Never use indices as keys - list mutations corrupt item state +- Use `derivedStateOf` for scroll-dependent UI +- Omit `state` on `LazyColumn`/`LazyRow` when you do not need programmatic scroll APIs; default `rememberLazyListState()` inside the lazy list is enough +- When you do hoist `LazyListState`, keep it in composition; avoid mirroring scroll indices into ViewModel state unless restoring scroll or meeting an explicit product requirement + +## View Composition Rules + +### Composable Naming + +- **PascalCase nouns** for UI components: `UserCard`, `LoginScreen`, `CheckboxWithLabel` +- **PascalCase verbs** for effect-only composables: `LaunchedEffect`, `TrackScreenView` +- Never ambiguous names like `HandleLogin` - is it UI or an effect? + +### Slot Pattern + +Accept `@Composable` lambda parameters for flexible, reusable containers: + +```kotlin +@Composable +fun SectionCard( + title: @Composable () -> Unit, + modifier: Modifier = Modifier, + actions: @Composable RowScope.() -> Unit = {}, + content: @Composable ColumnScope.() -> Unit +) { + Card(modifier = modifier) { + Column(Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + title() + Row(content = actions) + } + Spacer(Modifier.height(8.dp)) + content() + } + } +} + +// Usage - caller controls content +SectionCard( + title = { Text("Recent Activity", style = MaterialTheme.typography.titleMedium) }, + actions = { + IconButton(onClick = { }) { + Icon(painterResource(R.drawable.ic_filter), "Filter") + } + } +) { + ActivityList(items = events) +} +``` + +Pass `@Composable` lambdas, not pre-composed values. Optional slots use nullable lambdas with `?.invoke()`. + +### Never Return Values from Composables + +Composables execute during composition at unpredictable times. Always use callbacks: + +```kotlin +// Bad: composables must not return values +@Composable +fun UserInput(): String { + var text by remember { mutableStateOf("") } + TextField(value = text, onValueChange = { text = it }) + return text +} + +// Good +@Composable +fun UserInput(onValueChange: (String) -> Unit) { + var text by remember { mutableStateOf("") } + TextField( + value = text, + onValueChange = { + text = it + onValueChange(it) + } + ) +} +``` + +### Screen-Level Composable Structure + +Screens are a thin ViewModel integration layer. Keep ViewModel at screen level only - never pass to child composables: + +```kotlin +// Screen composable: connects ViewModel to pure UI +@Composable +fun ProductDetailScreen(viewModel: ProductDetailViewModel = hiltViewModel()) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + ProductDetailContent(uiState = uiState, onAction = viewModel::onAction) +} + +// Content composable: pure, testable, previewable +@Composable +private fun ProductDetailContent( + uiState: ProductUiState, + onAction: (ProductAction) -> Unit, + modifier: Modifier = Modifier +) { + // Pure UI rendering - no ViewModel dependency +} + +// Bad: ViewModel reaches a child +@Composable +fun ProductCard(viewModel: ProductDetailViewModel) { } + +// Good: child takes only data + callbacks +@Composable +fun ProductCard(product: Product, onClick: () -> Unit) { } +``` + +### Extraction Guidelines + +**Extract when:** +- Reused in multiple places +- Composable exceeds ~50 lines +- Independent concern (header, form, list item) +- Needs independent testing/preview + +**Don't extract when:** +- Single use and under ~10 lines (single `Text()` or `Icon()`) +- Would require passing 5+ parameters (over-extraction) +- Tightly coupled to parent logic + +## Deprecated Patterns & Migrations + +All migration guides have been consolidated into [migration.md](/references/migration.md). It covers: + +- Accompanist to official APIs +- Compose API migrations (`collectAsStateWithLifecycle`, `mutableIntStateOf`, `animateItem`, `Modifier.Node`, `Modifier.onFirstVisible` -> `Modifier.onVisibilityChanged`) +- Material 2 to Material 3 +- Scaffold `innerPadding` (mandatory) +- `@ExperimentalMaterial3Api` graduations +- Edge-to-edge +- Navigation string routes to Navigation3 +- XML to Compose +- LiveData to StateFlow +- RxJava to Coroutines + +## Forms & Input + +### Keyboard Configuration + +Set semantic `KeyboardOptions` so the system shows the correct keyboard layout: + +```kotlin +TextField( + value = email, + onValueChange = { email = it }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next + ), + keyboardActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Down) } + ) +) +``` + +Common keyboard types: + +| Input type | `keyboardType` | +|------------|-------------------------| +| Email | `KeyboardType.Email` | +| Phone | `KeyboardType.Phone` | +| Integer | `KeyboardType.Number` | +| Decimal | `KeyboardType.Decimal` | +| Password | `KeyboardType.Password` | +| URL | `KeyboardType.Uri` | + +### Autofill + +Enable autofill by setting `contentType` in semantics: + +```kotlin +TextField( + value = email, + onValueChange = { email = it }, + modifier = Modifier.semantics { + contentType = ContentType.EmailAddress + } +) + +TextField( + value = password, + onValueChange = { password = it }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.semantics { + contentType = ContentType.Password + } +) +``` + +### Password Visibility Toggle + +```kotlin +@Composable +fun PasswordField( + password: String, + onPasswordChange: (String) -> Unit, + modifier: Modifier = Modifier +) { + var passwordVisible by rememberSaveable { mutableStateOf(false) } + + TextField( + value = password, + onValueChange = onPasswordChange, + visualTransformation = if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = if (passwordVisible) "Hide password" else "Show password" + ) + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = modifier + ) +} +``` + +### Validation Timing + +Validate on focus-out, not on every keystroke. Per-keystroke validation creates a noisy experience +where errors flash while the user is still typing. + +```kotlin +@Composable +fun ValidatedEmailField( + email: String, + onEmailChange: (String) -> Unit, + modifier: Modifier = Modifier +) { + var hasBlurred by rememberSaveable { mutableStateOf(false) } + val isError = hasBlurred && !Patterns.EMAIL_ADDRESS.matcher(email).matches() + + TextField( + value = email, + onValueChange = onEmailChange, + isError = isError, + supportingText = if (isError) {{ Text("Invalid email address") }} else null, + modifier = modifier.onFocusChanged { state -> + if (!state.isFocused && email.isNotEmpty()) { + hasBlurred = true + } + } + ) +} +``` + +## Cross-references + +- [architecture.md](/references/architecture.md) — ViewModel patterns and state management +- [modularization.md](/references/modularization.md) — Feature modules and dependency rules +- [android-navigation.md](/references/android-navigation.md) — Navigation 3 and adaptive navigation +- [android-accessibility.md](/references/android-accessibility.md) — Semantics and TalkBack +- [android-theming.md](/references/android-theming.md) — Material 3, dynamic color, typography +- [android-i18n.md](/references/android-i18n.md) — Localization, RTL, string resources +- [kotlin-patterns.md](/references/kotlin-patterns.md) — Immutability and data classes +- [testing.md](/references/testing.md) — Compose UI tests +- [migration.md](/references/migration.md) — Accompanist, Compose, Material, RxJava, Navigation migrations + diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/references/coroutines-patterns.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/coroutines-patterns.md new file mode 100644 index 000000000..d2599f60e --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/coroutines-patterns.md @@ -0,0 +1,1618 @@ +# Coroutines Patterns + +## Android coroutine rules + +Use coroutines in a testable, lifecycle-aware way. Reference: [developer.android.com/kotlin/coroutines/coroutines-best-practices](https://developer.android.com/kotlin/coroutines/coroutines-best-practices). + +**Data Synchronization:** For retry mechanisms with exponential backoff and background sync patterns, see `references/android-data-sync.md`. + +### Inject Dispatchers (Avoid Hardcoding) + +Inject `CoroutineDispatcher` (or a small wrapper) so production and test behavior are consistent. +When providing multiple dispatchers of the same type, use `@Qualifier` annotations so Hilt can distinguish them (see `limitedParallelism` section below for a full example). + +```kotlin +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class IoDispatcher + +class AuthRepository @Inject constructor( + private val remote: AuthRemoteDataSource, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher +) { + suspend fun login(email: String, password: String): AuthResult = + withContext(ioDispatcher) { + remote.login(email, password) + } +} +``` + +### Use `limitedParallelism` for Custom Dispatcher Pools + +Use `limitedParallelism` instead of custom `ExecutorService` dispatchers — fewer threads and proper structured-concurrency integration. + +```kotlin +// Define qualifier annotations to distinguish dispatchers of the same type +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class DatabaseDispatcher + +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class CryptoDispatcher + +@Module +@InstallIn(SingletonComponent::class) +object DispatchersModule { + // Single-threaded dispatcher (e.g., for Room or SQLite operations) + @DatabaseDispatcher + @Provides + @Singleton + fun provideDatabaseDispatcher(): CoroutineDispatcher = + Dispatchers.IO.limitedParallelism(1) + + // Limited concurrency for CPU-intensive work + @CryptoDispatcher + @Provides + @Singleton + fun provideCryptoDispatcher(): CoroutineDispatcher = + Dispatchers.Default.limitedParallelism(4) +} + +// Usage - qualifier tells Hilt which dispatcher to inject +class AuthTokenEncryptor @Inject constructor( + @CryptoDispatcher private val cryptoDispatcher: CoroutineDispatcher +) { + suspend fun encrypt(token: AuthToken): EncryptedToken = withContext(cryptoDispatcher) { + performEncryption(token) + } +} +``` + +Benefits over custom ExecutorService: + +- Shares thread pool with parent dispatcher (more efficient) +- Proper integration with structured concurrency +- Automatic cleanup and resource management +- Better debugging and profiling support + +### Structured concurrency (not `GlobalScope`) + +Use `viewModelScope`/`lifecycleScope` for UI and inject external scope only when work must outlive UI. + +```kotlin +class AuthSessionRefresher( + private val authStore: AuthStore, + private val externalScope: CoroutineScope, + private val ioDispatcher: CoroutineDispatcher +) { + fun refreshSession() { + externalScope.launch(ioDispatcher) { + authStore.refresh() + } + } +} +``` + +### Make Coroutines Cancellable + +For long-running loops or blocking work, check for cancellation to keep UI responsive. + +```kotlin +class AuthLogUploader( + private val uploader: LogUploader +) { + suspend fun upload(files: List) { + for (file in files) { + ensureActive() + uploader.upload(file) + } + } +} +``` + +### Handle Exceptions Carefully + +Catch expected exceptions inside the coroutine. Never swallow `CancellationException`. + +```kotlin +@HiltViewModel +class AuthViewModel @Inject constructor( + private val loginUseCase: LoginUseCase, + private val savedStateHandle: SavedStateHandle +) : ViewModel() { + + fun login(email: String, password: String) { + viewModelScope.launch { + try { + loginUseCase(email, password) + } catch (e: IOException) { + // expose UI error state + } catch (e: CancellationException) { + throw e + } + } + } +} +``` + +### Do Not Catch `Throwable` + +Catch only expected exception types. Avoid `catch (Throwable)` because it includes fatal errors and +`CancellationException`. Use a `CoroutineExceptionHandler` for unexpected failures so cancellation +propagates correctly without manual rethrowing. + +```kotlin +private val crashHandler = CoroutineExceptionHandler { _, throwable -> + crashReporter.record(throwable) +} + +fun launchWithCrashReporting(block: suspend () -> Unit) { + viewModelScope.launch(crashHandler) { + block() + } +} +``` + +Note on `CoroutineExceptionHandler`: + +- `CoroutineExceptionHandler` only works when passed to the root coroutine (the initial `launch` or `async`). +It is ignored if passed to `withContext` or nested coroutines. + +If you must catch `Throwable` (rare), rethrow `CancellationException` immediately so structured +concurrency remains intact. + +### StateFlow for new code (not LiveData) + +Use `StateFlow` for observable state and `SharedFlow` or `Channel` for events. Reserve `LiveData` for interop +or legacy code that still requires it. **Migration Priority:** If the project plan allows, prioritize refactoring and migrating existing `LiveData` to `StateFlow` by following the guidelines in `references/migration.md` -> `## LiveData to StateFlow`. + +#### StateFlow vs SharedFlow vs Channel + +| Type | Best For | Behavior | +|--------------|---------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `StateFlow` | UI state (forms, loading, data) | Always holds one value. New collectors get the current value immediately. | +| `SharedFlow` | Data or signals many observers need at once | **Multicast:** every active collector can see the same emissions. Optional `replay` re-delivers the last N values to **new** collectors (watch for duplicate handling after rotation or back stack). With defaults similar to `MutableSharedFlow()`, `emit()` **suspends** until there is subscriber capacity rather than dropping; **loss** comes from `tryEmit`, `DROP_OLDEST` / `DROP_LATEST` under buffer pressure, or tight `replay` / buffer sizing. | +| `Channel` | One-shot **commands** (navigate, snackbar) | **Unicast:** each element is consumed once by one receiver. Buffered channels hold work until a collector runs; expose with `receiveAsFlow()` for lifecycle-aware collection. Design for **one** consumer (typical single UI collector). | + +**Commands vs data, unicast vs multicast** + +Treat navigation, snackbars, and dialogs as **commands**: they should run once per logical occurrence, not replay to every new observer. A `Channel` matches that shape: queued delivery to a single consumer, no accidental replay when a new collector starts. + +Treat "session invalidated", "theme changed", or global bus-style signals as **data** or **broadcasts**: several layers may need the same event. That is the natural fit for `SharedFlow` (multicast). + +**Required semantics for `SharedFlow`** + +- `replay = 1` (or higher) fixes "missed last value" for late subscribers but **re-fires** that value whenever a new collector appears. Wrong shape for one-shot commands after configuration change. +- `MutableSharedFlow` defaults to `onBufferOverflow = BufferOverflow.SUSPEND`. You do not need to pass `SUSPEND` unless you want the call site to document intent; you pass a **non-default** overflow (for example `DROP_OLDEST`) when you intentionally prefer loss or conflation over blocking the emitter. +- With the default `SUSPEND`, `emit()` tends to **suspend** when there is no capacity, not silently drop. Loss is more tied to `tryEmit`, choosing `DROP_OLDEST` / `DROP_LATEST`, or tight buffer sizing under load. +- Larger `replay` or `extraBufferCapacity` with the default `SUSPEND` adds queue space before `emit()` suspends; the emitter can still wait until collectors drain the buffer (typical `viewModelScope` usage tolerates this; cancel when the `ViewModel` clears). + +**Use when:** `Channel` + `receiveAsFlow()` for strict one-shot UI commands. `SharedFlow` when several collectors must observe the same emissions or when controlled replay is part of the product contract. + +```kotlin +@HiltViewModel +class AuthViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle +) : ViewModel() { + // State: always has a value, conflates rapid updates + private val _uiState = MutableStateFlow(AuthUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + // CORRECT: Channel = unicast one-shot commands; buffers until UI collects + private val _events = Channel(Channel.BUFFERED) + val events: Flow = _events.receiveAsFlow() + + // Use SharedFlow when several observers need the same event or replay/buffer tradeoffs are acceptable + // private val _events = MutableSharedFlow( + // replay = 0, + // extraBufferCapacity = 1, + // onBufferOverflow = BufferOverflow.DROP_OLDEST + // ) + // val events: SharedFlow = _events.asSharedFlow() + + fun login() { + viewModelScope.launch { + _uiState.value = AuthUiState.Loading + // ... do login ... + _events.send(AuthEvent.LoginSuccess) // Channel: suspends if buffer is full + // _events.emit(AuthEvent.LoginSuccess) // SharedFlow + } + } +} +``` + +**Common Mistake:** Using `StateFlow` for a one-off snackbar. +```kotlin +// Bad: StateFlow holds the value forever. You have to manually reset it to null after showing the snackbar. +private val _snackbarMessage = MutableStateFlow(null) + +// Good: same patterns as other one-shot commands (Channel preferred; SharedFlow if you accept its tradeoffs) +private val _snackbarMessage = Channel(Channel.BUFFERED) +val snackbarMessages: Flow = _snackbarMessage.receiveAsFlow() +// Or: MutableSharedFlow(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) +``` + +Note on buffering with SharedFlow: + +- `replay` controls how many values new subscribers receive. +- `extraBufferCapacity` adds temporary queue space for bursts from active emitters. +- For **one-shot commands**, `replay = 1` (or higher) replays to every new collector — wrong default. Use `replay = 0` with an explicit buffer/overflow policy, or + use a `Channel` instead of fighting multicast semantics. +When **late subscribers** must read only the **latest** value (state-like behavior), +`replay = 1` plus explicit `extraBufferCapacity` can match the product; treat that as sticky state, not +a consumed command. + +Guidance for events vs state: + +- **`Channel` + `receiveAsFlow()`** for strict one-shot commands (navigation, snackbars, + one-time dialogs). **`SharedFlow`** when multiple collectors observe the same stream or + replay to new subscribers is intended; size buffers and pick `onBufferOverflow` deliberately. +- **Best-effort** UI (some toasts, debug banners) may use a small `SharedFlow` if occasional drops are + acceptable; do not label navigation that way unless the product truly allows missing the action. +- If an event must survive the UI being stopped, persist it as state and render it on resume + (`StateFlow` / `ViewModel` state / persistence), rather than relying only on in-memory buffering. + +### Convert Cold Flows to Hot StateFlows with `stateIn` + +Use `stateIn` to share expensive Flow operations across multiple collectors and cache the latest value. +This prevents repeated work when multiple UI components observe the same data. + +```kotlin +@HiltViewModel +class AuthViewModel @Inject constructor( + private val authRepository: AuthRepository, + private val savedStateHandle: SavedStateHandle +) : ViewModel() { + // Cold flow: each collector triggers separate database query + private val authSessionFlow: Flow = authRepository.observeAuthSession() + + // Hot StateFlow: shared across all collectors, 5s stop timeout + val authSession: StateFlow = authSessionFlow + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), + initialValue = null + ) +} +``` + +Key `SharingStarted` strategies: + +- `WhileSubscribed(5000)`: Stops upstream flow 5s after last collector unsubscribes. Best for most UI cases (survives quick config changes, saves resources when backgrounded). +- `Eagerly`: Starts immediately and never stops. Use for critical always-needed state (auth status, app config). +- `Lazily`: Starts on first subscriber, never stops. Use when you want to keep the flow hot after first access. + +Common mistake: Using `stateIn` with `Eagerly` by default. Use `WhileSubscribed` to avoid wasted resources. + +### Share Expensive Upstream with `shareIn` + +Use `shareIn` to convert a cold Flow into a hot `SharedFlow` shared across multiple collectors. Unlike `stateIn`, it has no initial value and supports configurable replay. + +```kotlin +@HiltViewModel +class NotificationsViewModel @Inject constructor( + private val notificationRepository: NotificationRepository +) : ViewModel() { + // Expensive upstream: WebSocket connection + parsing + val notifications: SharedFlow = notificationRepository + .observeNotifications() + .shareIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + replay = 0 + ) +} +``` + +#### `stateIn` vs `shareIn` + + +| | `stateIn` | `shareIn` | +|-------------------|---------------------------------|------------------------------| +| Return type | `StateFlow` | `SharedFlow` | +| Initial value | Required | Not needed | +| Replay | Always 1 (latest) | Configurable (0, 1, n) | +| `.value` accessor | Yes | No | +| Use for | UI state, always-available data | Event streams, notifications | + + +**Rule:** If collectors need `.value` or the current state at any time, use `stateIn`. If collectors only care about emissions after subscribing, use `shareIn`. + +```kotlin +// stateIn: UI state - collectors need current value immediately +val userProfile: StateFlow = profileRepo.observe() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + +// shareIn: events - collectors only care about new emissions +val toastEvents: SharedFlow = eventBus.observe() + .shareIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), replay = 0) +``` + +### Combine Multiple Flows with `combine` + +Use `combine` to merge the **latest values** from multiple Flows into a single emission. Re-emits whenever any input Flow emits a new value. + +```kotlin +@HiltViewModel +class DashboardViewModel @Inject constructor( + private val userRepository: UserRepository, + private val settingsRepository: SettingsRepository, + private val connectivityObserver: ConnectivityObserver +) : ViewModel() { + val uiState: StateFlow = combine( + userRepository.observeUser(), + settingsRepository.observeSettings(), + connectivityObserver.observe() + ) { user, settings, connectivity -> + DashboardUiState( + userName = user.displayName, + theme = settings.theme, + isOffline = connectivity == ConnectivityStatus.Lost + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = DashboardUiState() + ) +} +``` + +#### `combine` vs `zip` + +- `combine` - emits on **any** input change using latest values from all. Use for independent state sources. +- `zip` - pairs emissions **1:1** in order, waits for both. Use for synchronized pairs. + +```kotlin +// combine: re-emits when either changes (independent sources) +combine(userFlow, settingsFlow) { user, settings -> Pair(user, settings) } + +// zip: waits for matching pairs (synchronized sources) +requestFlow.zip(responseFlow) { request, response -> Result(request, response) } +``` + +**Rule:** For ViewModel state composed from multiple repositories/data sources, always use `combine`. `zip` is rare - typically used for request/response pairing or synchronized streams. + +### Avoid `async` with Immediate `await` + +Don't use `async` followed immediately by `await` in the same scope. Use `withContext` for sequential work or call the suspend function directly. + +```kotlin +// Good: direct call or withContext for sequential work +suspend fun fetchAuthProfile(): AuthProfile { + val profile = withContext(Dispatchers.IO) { + authRemote.fetchProfile() + } + return profile.toDomain() +} + +// Good: simple sequential call +suspend fun refreshAuth(): AuthResult { + return authRemote.refresh() +} +``` + +### `launch` vs `async` vs `withContext` + +Use `launch` for side effects, `async` for parallel work that returns values, and `withContext` for sequential operations that need dispatcher switching or structured concurrency. + +```kotlin +// launch: fire-and-forget side effects +fun refreshAuthState() { + viewModelScope.launch { + authSyncer.refreshSession() + } +} + +// async: parallel work returning values +suspend fun loadAuthDashboard(): AuthDashboard = coroutineScope { + val deferreds = listOf( + async { authRemote.fetchUser() }, + async { authRemote.fetchSessions() }, + async { authRemote.fetchSecurityStatus() } + ) + + val (user, sessions, security) = deferreds.awaitAll() + + AuthDashboard(user, sessions, security) +} + +// withContext: sequential work with dispatcher switch +suspend fun processAuthData(data: AuthData): ProcessedAuth = withContext(Dispatchers.Default) { + data.process() +} +``` + +### Use `awaitAll` for Parallel Work + +Use `awaitAll()` so failures cancel remaining work promptly. It handles exceptions properly and cancels sibling coroutines when one fails. + +```kotlin +suspend fun syncAuthData(): SyncResult = coroutineScope { + try { + val results = listOf( + async { syncTokens() }, + async { syncPermissions() }, + async { syncPreferences() } + ).awaitAll() + + SyncResult.Success(results) + } catch (e: Exception) { + // All remaining work is cancelled on first failure + SyncResult.Failed(e) + } +} +``` + +### Keep Suspend/Flow Thread-Safe + +Suspend APIs must be safe to call from any dispatcher. Use `withContext` inside suspend functions and `flowOn` for +upstream flow work. Avoid dispatcher switching for trivial mapping logic, and keep domain and use-case layers dispatcher-agnostic. + +```kotlin +class AuthAuditRepository( + private val ioDispatcher: CoroutineDispatcher, + private val auditStore: AuditStore +) { + suspend fun readAuditLog(): List = + withContext(ioDispatcher) { + auditStore.readAll() + } +} +``` + +### Avoid Nested `withContext` Chains + +Do not stack multiple `withContext` calls across layers. Switch dispatchers at clear boundaries +(typically data sources) and keep domain/use cases dispatcher-agnostic to avoid thread hopping. + +```kotlin +class AuthRemoteDataSource( + private val ioDispatcher: CoroutineDispatcher, + private val api: AuthApi +) { + suspend fun fetchUser(): AuthUser = withContext(ioDispatcher) { + api.fetchUser() + } +} + +class FetchUserUseCase @Inject constructor( + private val dataSource: AuthRemoteDataSource +) { + suspend operator fun invoke(): AuthUser = + dataSource.fetchUser() +} +``` + +### Avoid Blocking Calls in Coroutines + +Do not call blocking APIs (`Thread.sleep`, blocking I/O, locks) on a coroutine thread. If unavoidable, +isolate the work on `Dispatchers.IO` (or a dedicated dispatcher). + +```kotlin +class AuthLegacyKeyStore( + private val ioDispatcher: CoroutineDispatcher, + private val legacyStore: LegacyKeyStore +) { + suspend fun loadKeys(): List = withContext(ioDispatcher) { + legacyStore.readKeysBlocking() + } +} +``` + +### `coroutineScope` vs `supervisorScope` - Failure Propagation + +Both are scope builders for use inside suspend functions. They differ on **what happens when one child fails**. + +| Aspect | `coroutineScope { }` | `supervisorScope { }` | +|--------------------------------|-------------------------------------------------------------|---------------------------------------------------------| +| Child failure cancels siblings | Yes | No | +| Failure rethrown to caller | Yes (first failure) | No (contained per-child) | +| Use when | Children form one atomic unit. Partial results are useless. | Children are independent. Partial results are valuable. | + +```kotlin +suspend fun loadDashboard() = coroutineScope { + val user = async { api.fetchUser() } + val orders = async { api.fetchOrders() } + Dashboard(user.await(), orders.await()) +} +``` + +If `fetchOrders()` fails, `fetchUser()` is cancelled and the exception is rethrown to the caller. Correct: there is no partial dashboard. + +```kotlin +suspend fun warmCaches() = supervisorScope { + launch { cache.warmTokens() } + launch { cache.warmFeatures() } + launch { cache.warmConfig() } +} +``` + +If `warmFeatures()` fails, `warmTokens` and `warmConfig` keep running. The function returns normally. Correct: a partially warmed cache is still a win. + +**Decision rule:** ask "if one child fails, is there any value in the others' results?" Yes → `supervisorScope`. No → `coroutineScope`. + +**With `async`:** in `supervisorScope`, exceptions thrown inside `async` are stored on the `Deferred` and only raised when you call `await()`. Always `await()` (or `awaitAll()`) every `async` you launch - see [Unawaited async in supervisorScope](#unawaited-async-in-supervisorscope). + +### `supervisorScope` vs `SupervisorJob` - Independent Child Failures + +Both let children fail independently without cancelling siblings. The difference is **where you use them** and **how exceptions are handled**. + +#### `supervisorScope` - Scoped Supervision with Automatic Exception Containment + +Use inside suspend functions. Child failures are contained automatically - they don't cancel siblings or propagate to the parent. The scope integrates with structured concurrency. + +```kotlin +suspend fun refreshAuthCaches(): Unit = supervisorScope { + launch { authCache.refreshTokens() } // if this fails, sessions still runs + launch { authCache.refreshSessions() } // independent of tokens +} +``` + +Exceptions from failed children are **contained** - they don't crash the app or propagate upward. You can optionally catch them inside each child for logging/recovery. + +#### `SupervisorJob` - Explicit Scope with Manual Error Handling + +Use when creating a `CoroutineScope` (Services, Repositories, custom scopes). Children fail independently, BUT **you must handle exceptions explicitly** - unhandled child exceptions still propagate to the `CoroutineExceptionHandler` or crash the app. + +```kotlin +class RelayConnectionService : Service() { + private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + Log.e("RelayService", "Child failed: ${throwable.message}", throwable) + } + + // SupervisorJob: children fail independently + // CoroutineExceptionHandler: REQUIRED to catch unhandled child exceptions + private val scope = CoroutineScope( + SupervisorJob() + Dispatchers.IO + exceptionHandler + ) + + fun connectToRelays(relays: List) { + relays.forEach { relay -> + scope.launch { + relay.connect() // if this throws, other relays continue + } + } + } + + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } +} +``` + +Without the `CoroutineExceptionHandler`, an unhandled exception from any child would crash the app - `SupervisorJob` only prevents sibling cancellation, it does not swallow exceptions. + +#### Use when + +| Scenario | Use | +|---------------------------------------------|-----------------------------------------------| +| Suspend function, parallel independent work | `supervisorScope` | +| Long-lived scope (Service, Repository) | `SupervisorJob` + `CoroutineExceptionHandler` | +| `withContext` + supervision needed | `supervisorScope` inside `withContext` | + +Forbidden: `withContext(SupervisorJob())` - see anti-pattern below. + + +#### Anti-Pattern: `withContext(SupervisorJob())` + +Never pass `SupervisorJob()` directly to `withContext`. It creates an orphaned root Job - cancellation from outside won't propagate in, and child exceptions have no handler. + +```kotlin +// BAD: orphaned Job, breaks structured concurrency, unhandled exceptions crash +suspend fun bad() = withContext(SupervisorJob()) { + launch { throw Exception() } // no handler - crashes app + launch { delay(1000) } // parent cancellation won't reach here +} + +// GOOD: supervisorScope for scoped supervision in suspend functions +suspend fun good() = supervisorScope { + launch { throw Exception() } // contained, siblings continue + launch { delay(1000) } // runs independently +} + +// GOOD: SupervisorJob scope with explicit error handling +val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + Log.e("Sync", "Child failed: ${throwable.message}", throwable) +} +val supervisedScope = CoroutineScope(SupervisorJob() + Dispatchers.IO + exceptionHandler) + +fun startParallelSync() { + supervisedScope.launch { syncUsers() } // if this fails, orders sync continues + supervisedScope.launch { syncOrders() } // independent of users sync +} +``` + +### Functions Returning `Flow` Should Not Be `suspend` + +Wrap any suspend setup inside the flow builder so collection triggers all work. + +```kotlin +fun observeAuthEvents(): Flow = flow { + val sources = authEventSources() + emitAll(sources.asFlow().flatMapMerge { it.observe() }) +} +``` + +### Use `flatMapLatest` for Sequential Flow Switching, `flatMapMerge` for Concurrent + +Choose the right flattening operator based on whether you want to cancel previous work or run it concurrently. + +```kotlin +// flatMapLatest: Cancels previous flow when input changes (search queries, user selections) +fun searchAuth(query: StateFlow): Flow> = + query.flatMapLatest { searchQuery -> + if (searchQuery.isEmpty()) { + flowOf(emptyList()) + } else { + authRepository.search(searchQuery) + } + } + +// flatMapMerge: Runs flows concurrently (multiple independent data sources) +fun observeAuthEvents(): Flow = flow { + val sources = authEventSources() + emitAll(sources.asFlow().flatMapMerge { it.observe() }) +} + +// flatMapConcat: Sequential, waits for each flow to complete (rare, order-dependent processing) +fun processAuthBatches(batches: Flow): Flow = + batches.flatMapConcat { batch -> + flow { emit(processBatch(batch)) } + } +``` + +When to use each: + +- `flatMapLatest`: User-driven changes (search, filters, selections) where only the latest matters +- `flatMapMerge`: Multiple independent sources running in parallel +- `flatMapConcat`: Order-dependent sequential processing (rare) + +### Backpressure & Rate Limiting + +When a Flow producer emits faster than the collector can process, the producer suspends by default (back-pressured). Use these operators to control that behavior explicitly. + +#### `buffer` - Decouple Producer and Collector + +Run producer and collector concurrently with a buffer in between. Producer keeps emitting without waiting for slow collector. + +```kotlin +sensorReadings() + .buffer(64) + .collect { reading -> + // Slow processing - producer keeps emitting into buffer + saveToDisk(reading) + } +``` + +With overflow strategy: + +```kotlin +highFrequencyEvents() + .buffer(capacity = 100, onBufferOverflow = BufferOverflow.DROP_OLDEST) + .collect { event -> + processEvent(event) + } +``` + +`BufferOverflow` strategies: + +- `SUSPEND` (default) - suspends producer when buffer full +- `DROP_OLDEST` - drops oldest buffered value, never suspends producer +- `DROP_LATEST` - drops newest emission, never suspends producer + +#### `conflate` - Keep Only Latest + +Shorthand for `buffer(CONFLATED)`. Collector always gets the most recent emission, skipping intermediate values. + +```kotlin +// UI only needs latest state - skip intermediate updates +locationUpdates() + .conflate() + .collect { location -> + updateMapMarker(location) + } +``` + +#### `debounce` - Wait for Quiet Period + +Emit only after no new emissions for the specified duration. Restarts timer on each emission. + +```kotlin +searchQueryFlow + .debounce(300) + .distinctUntilChanged() + .flatMapLatest { query -> repository.search(query) } + .collect { results -> updateUi(results) } +``` + +#### `sample` - Periodic Snapshots + +Emit the most recent value at fixed intervals, regardless of emission frequency. + +```kotlin +// Emit latest sensor reading every 100ms, even if sensor fires at 1000Hz +accelerometerFlow() + .sample(100) + .collect { reading -> updateDisplay(reading) } +``` + +#### Use when + +| Scenario | Operator | +|--------------------------------------------------|--------------------------| +| Slow collector, fast producer, all values matter | `buffer(capacity)` | +| Slow collector, only latest value matters | `conflate()` | +| Fast producer, drop old when full | `buffer(n, DROP_OLDEST)` | +| User input (search, text) | `debounce(ms)` | +| Continuous stream, periodic sampling | `sample(ms)` | +| Suppress consecutive duplicates | `distinctUntilChanged()` | + + +#### Anti-Pattern + +```kotlin +// BAD: Slow collector blocks fast producer, no backpressure handling +fastProducer() + .collect { item -> + heavyProcessing(item) // Producer suspended until this completes + } + +// GOOD: Buffer decouples producer and collector +fastProducer() + .buffer(64, BufferOverflow.DROP_OLDEST) + .collect { item -> + heavyProcessing(item) + } +``` + +### `suspend` for one-off values + +Use a suspending function when only a single value is expected. + +```kotlin +interface AuthRepository { + suspend fun fetchCurrentUser(): AuthUser +} +``` + +### Coroutine names for long-lived work + +For long-lived or background work, add `CoroutineName` to improve debugging and structured logs. + +```kotlin +class AuthSessionRefresher( + private val authStore: AuthStore, + private val externalScope: CoroutineScope, + private val ioDispatcher: CoroutineDispatcher +) { + fun startPeriodicRefresh() { + externalScope.launch(ioDispatcher + CoroutineName("AuthSessionRefresher")) { + while (isActive) { + authStore.refreshSessions() + delay(30.minutes) + } + } + } +} +``` + +### Avoid `Job` in `withContext` or Ad-Hoc `Job()` Usage + +Passing a `Job` into `withContext` breaks structured concurrency. Use `coroutineScope`/`supervisorScope` +and keep a reference to the returned `Job` when you need cancellation. + +```kotlin +class AuthSyncService( + private val scope: CoroutineScope, + private val authSyncer: AuthSyncer +) { + private var syncJob: Job? = null + + fun startSync() { + syncJob?.cancel() + syncJob = scope.launch { + authSyncer.syncAll() + } + } +} +``` + +### Yield During Heavy Work + +For long-running CPU-bound loops, periodically call `yield()` to allow rescheduling, or `ensureActive()` when only +cancellation checks are needed. Avoid using either in short-lived or already suspending work. + +```kotlin +suspend fun reconcileSessions(sessions: List) = withContext(Dispatchers.Default) { + sessions.forEachIndexed { index, session -> + if (index % 50 == 0) { + yield() + } + reconcile(session) + } +} +``` + +### ViewModels Should Launch Coroutines (Not Expose `suspend`) + +Keep async orchestration in the ViewModel. Expose UI triggers and let the ViewModel launch work. +Repositories/use cases remain `suspend`/`Flow`. + +```kotlin +@HiltViewModel +class AuthViewModel @Inject constructor( + private val loginUseCase: LoginUseCase, + private val savedStateHandle: SavedStateHandle +) : ViewModel() { + fun onLoginClick(email: String, password: String) { + viewModelScope.launch { + loginUseCase(email, password) + } + } +} +``` + +### Repositories/Use Cases Should Not Launch Coroutines + +Non-UI layers should expose `suspend` functions or `Flow` and let callers control scope/lifecycle. +This avoids hidden lifetimes and keeps cancellation/testability predictable. + +```kotlin +class AuthRepository( + private val remote: AuthRemoteDataSource +) { + suspend fun refreshSession(): AuthSession = + remote.refreshSession() +} + +class RefreshSessionUseCase @Inject constructor( + private val repository: AuthRepository +) { + suspend operator fun invoke(): AuthSession = + repository.refreshSession() +} + +@HiltViewModel +class AuthViewModel @Inject constructor( + private val refreshSessionUseCase: RefreshSessionUseCase, + private val savedStateHandle: SavedStateHandle +) : ViewModel() { + fun onRefreshSession() { + viewModelScope.launch { + refreshSessionUseCase() + } + } +} +``` + +### Treat NonCancellable as a Last Resort + +Use `NonCancellable` only for critical resource cleanup (such as camera, sensors, database connections, file handles) that +must complete even when the coroutine is cancelled. This prevents resource leaks but should be used sparingly. + +`NonCancellable` doesn't prevent cancellation; it allows suspended functions to complete during the cancelling state. Keep cleanup code fast and bounded. + +```kotlin +class CameraRepository( + private val camera: Camera, // CameraX or hardware wrapper + private val ioDispatcher: CoroutineDispatcher +) { + suspend fun capturePhoto(): Photo = withContext(ioDispatcher) { + try { + camera.open() + camera.capture() + } finally { + // Critical: release hardware even if cancelled + withContext(NonCancellable) { + camera.close() + } + } + } +} +``` + +Warning: Never wrap normal business logic in `NonCancellable`. It should only guard cleanup code that prevents resource leaks or corruption. + +### Timeouts for hardware and uncontrolled APIs + +Use `withTimeout` or `withTimeoutOrNull` for operations that can hang indefinitely when interacting with hardware or third-party SDKs without built-in timeout mechanisms. + +Configure HTTP timeouts at the client level (OkHttp, Ktor). Use `withTimeout`/`withTimeoutOrNull` only for APIs that expose no timeout control of their own (hardware SDKs, third-party callbacks). + +```kotlin +class BiometricAuthRepository( + private val biometricSdk: ThirdPartyBiometricSdk, + private val ioDispatcher: CoroutineDispatcher +) { + suspend fun authenticate(): BiometricResult? = + withTimeoutOrNull(30.seconds) { + withContext(ioDispatcher) { + biometricSdk.authenticate() + } + } +} + +class HardwarePrinterRepository( + private val printerSdk: ThirdPartyPrinterSdk, + private val ioDispatcher: CoroutineDispatcher +) { + suspend fun print(document: PrintDocument): PrintResult = + try { + withTimeout(60.seconds) { + withContext(ioDispatcher) { + printerSdk.print(document) + } + } + } catch (e: TimeoutCancellationException) { + PrintResult.Timeout + } +} +``` + +- `withTimeout` throws `TimeoutCancellationException` (a `CancellationException`); it cancels the coroutine unless caught. +- Always wrap `withContext` *inside* `withTimeout`, never the reverse. The timeout must cover the dispatcher switch. +- Use `withTimeoutOrNull` when `null` is an acceptable outcome. Use `withTimeout` when timeout must be distinguished from other failures. + +## Bridging Imperative Callbacks to Coroutines + +Android and third-party SDKs expose many callback-based APIs. Use the right bridge depending on whether the callback produces **a stream of values** or **a single result**. + + +| Scenario | Use | +|--------------------------------------------------------|-------------------------------| +| Callback fires **multiple times** (listener, observer) | `callbackFlow` | +| Need **multiple concurrent coroutine producers** | `channelFlow` | +| Callback fires **once** (completion, result) | `suspendCancellableCoroutine` | + + +### `callbackFlow` - Callback Stream to Flow + +Use `callbackFlow` to convert listener/observer callback APIs into cold Flows. Required for Android system APIs that use listener/callback patterns (ConnectivityManager, LocationManager, sensors, BroadcastReceiver). + +### Core Pattern + +```kotlin +fun observeNetworkStatus( + connectivityManager: ConnectivityManager +): Flow = callbackFlow { + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + trySend(NetworkStatus.Available) + } + override fun onLost(network: Network) { + trySend(NetworkStatus.Lost) + } + override fun onCapabilitiesChanged( + network: Network, + capabilities: NetworkCapabilities + ) { + trySend(NetworkStatus.Changed(capabilities)) + } + } + + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + + connectivityManager.registerNetworkCallback(request, callback) + + awaitClose { + connectivityManager.unregisterNetworkCallback(callback) + } +} +``` + +**Rules:** + +- **Always call `awaitClose {}`** - even if cleanup is empty. Without it, the flow closes immediately after the builder block completes. +- **Use `trySend()` from callbacks, not `send()`** - `trySend` is non-suspending and safe to call from any thread. `send()` is suspending and will throw if called from a non-coroutine context. +- **Callback registration APIs must be thread-safe** - `awaitClose` cleanup can race callback delivery, so `register`/`unregister` must be safe under concurrent calls. +- **Emit initial state before registering callback** - prevents collectors from missing the current value. +- **Unregister/cleanup in `awaitClose`** - mirrors the lifecycle of the collector. + +#### Emit Initial State + +```kotlin +fun observeLocationUpdates( + locationManager: LocationManager +): Flow = callbackFlow { + // Emit last known location immediately + val lastKnown = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) + lastKnown?.let { trySend(it) } + + val listener = LocationListener { location -> trySend(location) } + + locationManager.requestLocationUpdates( + LocationManager.GPS_PROVIDER, 5000L, 10f, listener + ) + + awaitClose { locationManager.removeUpdates(listener) } +} +``` + +#### BroadcastReceiver as Flow + +```kotlin +fun Context.observeBatteryLevel(): Flow = callbackFlow { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) + trySend(level) + } + } + + registerReceiver(receiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + + awaitClose { unregisterReceiver(receiver) } +} +``` + +#### Stabilize Rapidly Changing Callbacks + +Combine `callbackFlow` with Flow operators to stabilize flapping signals: + +```kotlin +fun observeStableNetworkStatus( + connectivityManager: ConnectivityManager +): Flow = + observeNetworkStatus(connectivityManager) + .distinctUntilChanged() + .debounce(200) + .flowOn(Dispatchers.IO) +``` + +#### `callbackFlow` Anti-Patterns + +```kotlin +// BAD: Missing awaitClose - flow completes immediately +fun badFlow(): Flow = callbackFlow { + api.registerListener { trySend(it) } + // Flow closes here! Listener never cleaned up +} + +// GOOD: Always include awaitClose +fun goodFlow(): Flow = callbackFlow { + val cleanedUp = java.util.concurrent.atomic.AtomicBoolean(false) + val listener = EventListener { trySend(it) } + fun cleanupOnce() { + if (cleanedUp.compareAndSet(false, true)) { + api.unregisterListener(listener) + } + } + api.registerListener(listener) + awaitClose { cleanupOnce() } +} +``` + +```kotlin +// BAD: Using send() from callback thread +fun badFlow(): Flow = callbackFlow { + api.registerListener { event -> + send(event) // Compile error or crash: send is suspending + } + awaitClose { api.unregisterListener() } +} + +// GOOD: Use trySend() from callbacks +fun goodFlow(): Flow = callbackFlow { + api.registerListener { event -> + trySend(event) // Non-suspending, thread-safe + } + awaitClose { api.unregisterListener() } +} +``` + +```kotlin +// BAD: Non-thread-safe awaitClose cleanup (races with callback thread cleanup) +fun badCleanupFlow(): Flow = callbackFlow { + var cleanedUp = false + + val callback = object : StreamingCallback { + override fun onNext(event: Event) { + trySend(event) + } + + override fun onClosed() { + if (!cleanedUp) { + cleanedUp = true + api.unregisterCallback(this) // can race with awaitClose path + } + channel.close() + } + } + + api.registerCallback(callback) + + awaitClose { + if (!cleanedUp) { // Race: callback thread may pass this check too + cleanedUp = true + api.unregisterCallback(callback) + } + } +} + +// GOOD: Idempotent cleanup shared by callback and awaitClose +fun goodCleanupFlow(): Flow = callbackFlow { + val cleanedUp = java.util.concurrent.atomic.AtomicBoolean(false) + + val callback = object : StreamingCallback { + override fun onNext(event: Event) { + trySend(event) + } + + override fun onClosed() { + cleanupOnce() + channel.close() + } + } + + fun cleanupOnce() { + if (cleanedUp.compareAndSet(false, true)) { + api.unregisterCallback(callback) + } + } + + api.registerCallback(callback) + awaitClose { cleanupOnce() } +} +``` + +#### `channelFlow` - Multiple Coroutine Producers + +Use `channelFlow` when you need multiple coroutines producing into the same Flow. No `awaitClose` requirement. + +```kotlin +fun mergeFeeds(repos: List): Flow = channelFlow { + repos.forEach { repo -> + launch { + repo.getFeed().collect { send(it) } + } + } +} +``` + +### `suspendCancellableCoroutine` - One-Shot Callback to Suspend + +Use `suspendCancellableCoroutine` to convert a **single-result** callback into a suspend function. The coroutine suspends until `resume` or `resumeWithException` is called exactly once. + +**Always prefer `suspendCancellableCoroutine` over `suspendCoroutine`** - it supports cancellation, which is critical for structured concurrency. + +`suspendCoroutine` is acceptable only for narrow cases where all of the following are true: + +- The API is truly one-shot and guaranteed to invoke exactly one terminal callback. +- There is no meaningful cancellation or cleanup path to execute. +- The operation is short-lived and does not hold scarce resources while waiting. +- You still enforce exact-once resume semantics. + +#### Core Pattern + +```kotlin +suspend fun authenticate(biometricManager: BiometricManager): AuthResult = + suspendCancellableCoroutine { continuation -> + biometricManager.authenticate( + onSuccess = { token -> + continuation.resume(token) + }, + onError = { error -> + continuation.resumeWithException(AuthException(error)) + } + ) + + continuation.invokeOnCancellation { + biometricManager.cancel() + } + } +``` + +#### Common Use Cases + +For Google Play Services and Firebase APIs that return `Task`, prefer the official coroutine +adapter from `kotlinx-coroutines-play-services` (`import kotlinx.coroutines.tasks.await`) instead of +maintaining custom `Task.await()` bridges. + +```kotlin +import kotlinx.coroutines.tasks.await + +// One-shot location request via official Task.await() +suspend fun getLastLocation( + fusedLocationClient: FusedLocationProviderClient +): Location = + fusedLocationClient.lastLocation.await() + ?: throw LocationNotFoundException() +``` + +#### Returning Closeable Resources Safely + +`suspendCancellableCoroutine` has prompt cancellation guarantees. If a callback returns a closeable +resource, cancellation may happen after `resume(resource)` but before the caller receives it. Use +`resume(value) { ... }` to close the resource in that race window. + +```kotlin +suspend fun openFileHandle(api: FileApi): FileHandle = + suspendCancellableCoroutine { cont -> + api.openAsync( + onSuccess = { handle -> + cont.resume(handle) { _, handleToClose, _ -> + handleToClose.close() + } + }, + onError = { error -> + cont.resumeWithException(error) + } + ) + + cont.invokeOnCancellation { + api.cancelOpen() + } + } +``` + +#### Rules + +- **For APIs returning `Task`, use official `await()` adapters** - prefer `kotlinx.coroutines.tasks.await` over custom bridge extensions. +- **Use `suspendCancellableCoroutine` for one-shot callbacks that are not `Task`** - custom bridging still applies when no official adapter exists. +- **When resuming with closeable resources, use `resume(value) { ... }`** - ensures cancellation-time cleanup if the coroutine is cancelled before the caller observes the resource. +- **Treat `suspendCoroutine` as an exception, not a default** - use it only for truly non-cancellable, short-lived one-shot callbacks with no cleanup requirements. +- **Call `resume`/`resumeWithException` exactly once** - multiple calls throw `IllegalStateException`. Guard with `cont.isActive` when the callback can fire after cancellation. +- **Always implement `invokeOnCancellation`** - clean up resources (cancel requests, unregister listeners) when the coroutine is cancelled. +- **Cancellation cleanup must be thread-safe** - callbacks may fire concurrently with `invokeOnCancellation`, so cancellation/unregister logic must tolerate races. +- **Never block inside the lambda** - the lambda runs synchronously on the caller's thread. Register the callback and return immediately. + +#### One-Shot Bridge Checklist + +Before merging any `suspendCancellableCoroutine` bridge, confirm: + +- Every success/error/disconnect callback path either `resume(...)` or `resumeWithException(...)`. +- No path can resume twice (guard multi-fire callbacks with `cont.isActive` and idempotent cleanup). +- Cancellation unregisters/cancels underlying work via `invokeOnCancellation`. +- If the API can stall indefinitely, wrap the bridge call with `withTimeout`/`withTimeoutOrNull`. +- Cleanup/unregister logic is race-safe between callback thread and cancellation thread. + +```kotlin +suspend fun awaitConnectSafe(client: LegacyClient): Connection = + withTimeout(5.seconds) { + suspendCancellableCoroutine { cont -> + val cleanedUp = java.util.concurrent.atomic.AtomicBoolean(false) + + fun cleanupOnce() { + if (cleanedUp.compareAndSet(false, true)) { + client.disconnect() + } + } + + client.connect( + onConnected = { connection -> + if (cont.isActive) cont.resume(connection) + cleanupOnce() + }, + onError = { error -> + if (cont.isActive) cont.resumeWithException(error) + cleanupOnce() + }, + onDisconnected = { + if (cont.isActive) { + cont.resumeWithException(IllegalStateException("Disconnected before connect")) + } + cleanupOnce() + } + ) + + cont.invokeOnCancellation { cleanupOnce() } + } + } +``` + +#### Anti-Patterns + +```kotlin +// BAD: Using suspendCoroutine - ignores cancellation +suspend fun badFetch(): Result = suspendCoroutine { cont -> + api.fetch { result -> cont.resume(result) } + // If coroutine is cancelled, api.fetch keeps running and cont.resume may crash +} + +// GOOD: Using suspendCancellableCoroutine +suspend fun goodFetch(): Result = suspendCancellableCoroutine { cont -> + val call = api.fetch { result -> + if (cont.isActive) cont.resume(result) + } + cont.invokeOnCancellation { call.cancel() } +} +``` + +```kotlin +// BAD: Resuming multiple times +suspend fun bad(): String = suspendCancellableCoroutine { cont -> + api.onSuccess { cont.resume(it) } + api.onRetry { cont.resume(it) } // Crash: already resumed +} + +// GOOD: Guard with isActive +suspend fun good(): String = suspendCancellableCoroutine { cont -> + api.onSuccess { if (cont.isActive) cont.resume(it) } + api.onRetry { if (cont.isActive) cont.resume(it) } +} +``` + +```kotlin +// BAD: Non-thread-safe invokeOnCancellation cleanup (races with callback thread) +suspend fun badCleanup(): Result = suspendCancellableCoroutine { cont -> + var cleanedUp = false + + val callback = object : ApiCallback { + override fun onSuccess(value: Result) { + if (cont.isActive) cont.resume(value) + if (!cleanedUp) { + cleanedUp = true + api.unregister(this) + } + } + + override fun onError(error: Throwable) { + if (cont.isActive) cont.resumeWithException(error) + if (!cleanedUp) { + cleanedUp = true + api.unregister(this) + } + } + } + + api.register(callback) + + cont.invokeOnCancellation { + if (!cleanedUp) { // Race: callback thread may pass this check too + cleanedUp = true + api.unregister(callback) + } + } +} + +// GOOD: Idempotent cleanup shared by callback and cancellation paths +suspend fun goodCleanup(): Result = suspendCancellableCoroutine { cont -> + val cleanedUp = java.util.concurrent.atomic.AtomicBoolean(false) + + val callback = object : ApiCallback { + override fun onSuccess(value: Result) { + if (cont.isActive) cont.resume(value) + cleanupOnce() + } + + override fun onError(error: Throwable) { + if (cont.isActive) cont.resumeWithException(error) + cleanupOnce() + } + } + + fun cleanupOnce() { + if (cleanedUp.compareAndSet(false, true)) { + api.unregister(callback) + } + } + + api.register(callback) + cont.invokeOnCancellation { cleanupOnce() } +} +``` + +### Preserve Error Shape in Bridge APIs + +When bridging callback APIs to suspend functions, keep the callback's data shape and error semantics +intact instead of flattening everything to generic `Exception(message)`. + +#### Multi-Value Success -> Wrapper Result Type + +If success callbacks return multiple values, wrap them in a single result type so the suspend API +stays explicit and strongly typed. + +```kotlin +data class PurchaseBridgeResult( + val transaction: StoreTransaction, + val customerInfo: CustomerInfo +) + +suspend fun purchase(params: PurchaseParams): PurchaseBridgeResult = + suspendCancellableCoroutine { cont -> + sdk.purchase( + params = params, + onSuccess = { tx, info -> + cont.resume(PurchaseBridgeResult(tx, info)) + }, + onError = { error -> + cont.resumeWithException(PurchaseBridgeException(error)) + } + ) + } +``` + +#### Typed Exceptions -> Preserve Programmatic Handling + +Map SDK/domain errors to typed exceptions that keep machine-readable fields (codes, cancellation +reason, retryability) so callers can branch safely. + +```kotlin +open class PurchaseBridgeException( + val error: PurchaseError +) : Exception(error.message) + +class PurchaseCancelledException( + error: PurchaseError +) : PurchaseBridgeException(error) + +suspend fun restorePurchases(): CustomerInfo = + suspendCancellableCoroutine { cont -> + sdk.restore( + onSuccess = { info -> cont.resume(info) }, + onError = { error -> + val exception = if (error.code == PurchaseErrorCode.UserCancelled) { + PurchaseCancelledException(error) + } else { + PurchaseBridgeException(error) + } + cont.resumeWithException(exception) + } + ) + } +``` + +#### Anti-Pattern: Losing Error Metadata + +```kotlin +// BAD: Throws away typed error code/cause metadata +onError = { error -> + cont.resumeWithException(Exception(error.message)) +} + +// GOOD: Preserve structured error for caller handling +onError = { error -> + cont.resumeWithException(PurchaseBridgeException(error)) +} +``` + +## Common Pitfalls + +Quick-reference table of coroutine and Flow mistakes that are easy to miss during code review. + +### Redundant SupervisorJob in ViewModel + +`viewModelScope` already uses `SupervisorJob` internally. Adding another one creates a detached scope +that breaks structured concurrency. + +```kotlin +// BAD: redundant SupervisorJob, creates orphaned scope +class MyViewModel : ViewModel() { + private val scope = CoroutineScope(viewModelScope.coroutineContext + SupervisorJob()) + + fun load() { + scope.launch { /* ... */ } // not cancelled when ViewModel clears + } +} + +// GOOD: viewModelScope already has SupervisorJob +class MyViewModel : ViewModel() { + fun load() { + viewModelScope.launch { /* ... */ } + } +} +``` + +### Unawaited async in supervisorScope + +In `supervisorScope`, exceptions from unawaited `async` blocks are silently swallowed. The deferred +completes exceptionally but nobody observes it. Use `launch` when you don't need the result. + +```kotlin +// BAD: exception silently lost - nobody calls await() +suspend fun syncAll() = supervisorScope { + async { syncUsers() } // if this throws, nobody knows + async { syncOrders() } // same problem +} + +// GOOD: use launch when you don't need the return value +suspend fun syncAll() = supervisorScope { + launch { syncUsers() } + launch { syncOrders() } +} +``` + +### Side Effects Inside combine/map Transforms + +Transform lambdas in `combine`, `map`, and similar operators re-execute on every resubscription +(e.g., after screen rotation). Launching coroutines or emitting events inside them causes +duplicate side effects. + +```kotlin +// BAD: launches a coroutine on every resubscription (rotation fires it again) +val uiState = combine(userFlow, settingsFlow) { user, settings -> + viewModelScope.launch { analytics.trackView(user.id) } // fires on every rotation + UiState(user, settings) +} + +// GOOD: move side effects to onEach or a dedicated handler +val uiState = combine(userFlow, settingsFlow) { user, settings -> + UiState(user, settings) +}.onEach { state -> + analytics.trackView(state.user.id) +} +``` + +### Collecting a Flow Inside a Transform + +Calling `.first()` or `.firstOrNull()` inside a `map` or `combine` lambda creates a hidden +sequential fetch that re-executes on every upstream emission. Use `combine` to merge both flows +reactively instead. + +```kotlin +// BAD: hidden suspend call inside combine, re-fetches on every emission +val uiState = userFlow.map { user -> + val settings = settingsFlow.first() // blocks, re-fetches every time userFlow emits + UiState(user, settings) +} + +// GOOD: combine both flows reactively +val uiState = combine(userFlow, settingsFlow) { user, settings -> + UiState(user, settings) +} +``` + +### Manual Job Cancellation Instead of flatMapLatest + +A common pattern is cancelling a previous `Job` and re-launching on new input. `flatMapLatest` +handles this automatically and is less error-prone. + +```kotlin +// BAD: manual Job? tracking +class SearchViewModel : ViewModel() { + private var searchJob: Job? = null + + fun onQueryChanged(query: String) { + searchJob?.cancel() + searchJob = viewModelScope.launch { + val results = repository.search(query) + _uiState.update { it.copy(results = results) } + } + } +} + +// GOOD: flatMapLatest cancels previous automatically +class SearchViewModel : ViewModel() { + private val query = MutableStateFlow("") + + val uiState = query + .debounce(300) + .flatMapLatest { q -> repository.search(q) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + fun onQueryChanged(q: String) { query.value = q } +} +``` + +### `emit` vs `tryEmit` + +`emit` suspends until the value is delivered. `tryEmit` returns `Boolean` (`false` = dropped because the buffer was full). Inside a coroutine, default to `emit`. Use `tryEmit` only from non-suspending contexts, or when dropping a value is acceptable. + +With `BufferOverflow.DROP_OLDEST`/`DROP_LATEST`, `tryEmit` always returns `true`. Still prefer `emit` inside coroutines so the call stays correct if the overflow strategy changes. + +### Using emit() on MutableStateFlow + +`MutableStateFlow.emit()` is a suspending function but behaves identically to `.value =` assignment. +Using `emit()` misleads readers into thinking suspension is meaningful and adds unnecessary overhead. + +```kotlin +// MISLEADING: emit() suspends but does nothing extra on MutableStateFlow +viewModelScope.launch { + _uiState.emit(UiState.Loading) // no benefit over .value = +} + +// CLEAR: direct assignment +_uiState.value = UiState.Loading +``` + +Use `.value =` for `MutableStateFlow`. Reserve `emit()` for `MutableSharedFlow` where suspension +actually matters (it suspends when the buffer is full). + +## Coexisting with RxJava (Legacy Code) + +For RxJava coexistence patterns (StateFlow bridge, disposal management, paging) and the +RxJava-to-Coroutines migration path, see [migration.md](/references/migration.md#rxjava-to-coroutines). \ No newline at end of file diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/references/crashlytics.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/crashlytics.md new file mode 100644 index 000000000..8f9f80bbc --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/crashlytics.md @@ -0,0 +1,693 @@ +# Crash Reporting (Firebase Crashlytics / Sentry) + +Required: +- SDK-specific code lives only in `core:data` (or `core:analytics`); feature modules call a `CrashReporter` interface from `core:domain`. +- Provider initialization happens once, in the `app` module. +- Swap providers by changing the Hilt binding + convention plugin only - never by touching feature code. +- Play Vitals is optional store-level signal, not a Crashlytics replacement: see [android-performance.md → Optional: Play Vitals observability](/references/android-performance.md#optional-play-vitals-observability-play-developer-reporting-api). + +## Architecture Placement + +| Layer | Module | Responsibility | +|-------------|-----------------------------------|-------------------------------------------------| +| Contract | `core:domain` / `core:common` | `CrashReporter` interface, event models | +| Adapter | `core:data` / `core:analytics` | Firebase or Sentry implementation | +| Composition | `app` | Init + Hilt binding for chosen provider | + +## Provider-Agnostic Interface + +```kotlin +// core/domain/analytics/CrashReporter.kt +interface CrashReporter { + fun setUserId(id: String?) + fun setUserProperty(key: String, value: String) + fun log(message: String) + fun recordException(throwable: Throwable, context: Map = emptyMap()) +} +``` + +## Implementation Examples + +### Firebase Crashlytics + +```kotlin +// core/data/analytics/FirebaseCrashReporter.kt +class FirebaseCrashReporter @Inject constructor( + private val crashlytics: FirebaseCrashlytics +) : CrashReporter { + override fun setUserId(id: String?) { + crashlytics.setUserId(id ?: "") + } + + override fun setUserProperty(key: String, value: String) { + crashlytics.setCustomKey(key, value) + } + + override fun log(message: String) { + crashlytics.log(message) + } + + override fun recordException( + throwable: Throwable, + context: Map + ) { + context.forEach { (k, v) -> crashlytics.setCustomKey(k, v) } + crashlytics.recordException(throwable) + } +} +``` + +### Sentry + +```kotlin +// core/data/analytics/SentryCrashReporter.kt +class SentryCrashReporter @Inject constructor() : CrashReporter { + override fun setUserId(id: String?) { + val user = User().apply { this.id = id } + Sentry.setUser(user) + } + + override fun setUserProperty(key: String, value: String) { + Sentry.setTag(key, value) + } + + override fun log(message: String) { + Sentry.addBreadcrumb(message) + } + + override fun recordException( + throwable: Throwable, + context: Map + ) { + Sentry.withScope { scope -> + context.forEach { (k, v) -> scope.setTag(k, v) } + Sentry.captureException(throwable) + } + } +} +``` + +Use `Sentry.withScope` (Isolated/Local Scope) so per-call tags do not leak into subsequent events. + +## Sentry Setup (Convention Plugin + Compose) + +Apply the Sentry convention plugin. It applies the Gradle plugins, adds the Sentry SDK, and wires Compose integration. + +```kotlin +// app/build.gradle.kts +plugins { + alias(libs.plugins.app.android.application) + alias(libs.plugins.app.android.application.compose) + alias(libs.plugins.app.hilt) + alias(libs.plugins.app.sentry) +} +``` + +`app.sentry` (`assets/convention/SentryConventionPlugin.kt`) applies: +- `io.sentry.android.gradle` (core SDK + mapping upload) +- `io.sentry.kotlin.compiler.gradle` (`@Composable` auto-tagging) +- `sentry-android` and `sentry-compose-android` dependencies + +**Manual setup (if not using convention plugin):** + +```kotlin +plugins { + alias(libs.plugins.app.android.application) + alias(libs.plugins.app.android.application.compose) + alias(libs.plugins.sentry.android) + alias(libs.plugins.sentry.kotlin.compiler) +} + +dependencies { + implementation(libs.sentry.android) + implementation(libs.sentry.compose.android) +} +``` + +### Manifest Configuration + +Sentry uses a ContentProvider for auto-initialization. Configure via `AndroidManifest.xml`. + +```xml + + + + + + + +``` + +### Application Initialization (Sentry) + +Enable `options.logs.isEnabled` so StrictMode `.penaltyLog()` events can be shipped. Pick exactly one of `profilesSampleRate` or `profileSessionSampleRate` - never both. + +```kotlin +class MyApplication : Application() { + override fun onCreate() { + super.onCreate() + SentryAndroid.init(this) { options -> + options.dsn = "YOUR_DSN_HERE" + options.logs.isEnabled = true + options.environment = if (BuildConfig.DEBUG) "debug" else "production" + options.release = BuildConfig.VERSION_NAME + options.tracesSampleRate = 1.0 + // options.tracesSampler = { 0.2 } // Use a sampler when traces need dynamic sampling rates. + // options.tracePropagationTargets = listOf("api.example.com", "https://auth.example.com") + // options.propagateTraceparent = true + // options.traceOptionsRequests = false + + // Profiling configuration: + // profilesSampleRate: % of transactions to profile (requires tracesSampleRate > 0) + // Use this for production profiling of sampled transactions + options.profilesSampleRate = 1.0 + + // Alternative: profileSessionSampleRate profiles % of sessions (not transactions) + // Only use ONE of profilesSampleRate OR profileSessionSampleRate, not both + // options.profileSessionSampleRate = 0.2 + + // options.profileLifecycle = SentryOptions.ProfileLifecycle.TRACE + // options.startProfilerOnAppStart = true + options.enableAutoSessionTracking = true + options.sendDefaultPii = false + // options.sampleRate = 1.0 // Error event sampling. + // options.maxBreadcrumbs = 100 + // options.attachStacktrace = true + // options.attachThreads = false + // options.collectAdditionalContext = true + // options.inAppIncludes = listOf("com.example") + // options.inAppExcludes = listOf("com.example.core.testing") + } + } +} +``` + +Optional knobs (use only when justified): `tracesSampler`, `tracePropagationTargets`, `propagateTraceparent`, `traceOptionsRequests`, `profileLifecycle`, `startProfilerOnAppStart`, `maxBreadcrumbs`, `attachStacktrace`, `attachThreads`, `inAppIncludes`, `inAppExcludes`. + +### Jetpack Compose Specifics + +- Navigation breadcrumbs + transactions are auto-recorded when `androidx.navigation` is on the classpath. +- `sentry-kotlin-compiler` tags composables by function name; do not add `Modifier.sentryTag()` manually. +- Wrap critical screens with `SentryTraced` for explicit transactions. + +```kotlin +import io.sentry.compose.SentryTraced + +@Composable +fun AuthProfileScreen(userId: String) { + SentryTraced(name = "auth_profile_screen") { + Column { + Text("User: $userId") + } + } +} +``` + +## Firebase Crashlytics Setup (Convention Plugin + Compose) + +Apply the Firebase convention plugin. The separate `-ktx` artifact is not required with the Firebase BoM. + +```kotlin +// app/build.gradle.kts +plugins { + alias(libs.plugins.app.android.application) + alias(libs.plugins.app.android.application.compose) + alias(libs.plugins.app.hilt) + alias(libs.plugins.app.firebase) +} +``` + +`app.firebase` (`assets/convention/FirebaseConventionPlugin.kt`) applies: +- `com.google.gms.google-services` and `com.google.firebase.crashlytics` +- Firebase BoM (centralized version) +- `firebase-analytics` + `firebase-crashlytics` +- Native symbol upload + debug build settings + +**Manual setup (if not using convention plugin):** + +```kotlin +// build.gradle.kts (project-level) +plugins { + alias(libs.plugins.google.services) apply false + alias(libs.plugins.firebase.crashlytics) apply false +} + +// app/build.gradle.kts +plugins { + alias(libs.plugins.app.android.application) + alias(libs.plugins.app.android.application.compose) + alias(libs.plugins.google.services) + alias(libs.plugins.firebase.crashlytics) +} + +dependencies { + val bom = libs.firebase.bom + implementation(platform(bom)) + implementation(libs.firebase.analytics) + implementation(libs.firebase.crashlytics) +} +``` + +### Application Initialization (Firebase) + +```kotlin +class MyApplication : Application() { + override fun onCreate() { + super.onCreate() + FirebaseApp.initializeApp(this) + } +} +``` + +### Compose Screen Tracking (Navigation3) + +Crashlytics breadcrumbs do not automatically include Compose destination names. +Log screen transitions in the **app-level** `AppNavigation()` coordinator. +See the centralized navigation setup in `references/android-navigation.md`. + +```kotlin +@Composable +fun AppNavigation( + analytics: Analytics // Injected via Hilt +) { + val navigationState = rememberNavigationState( + startRoute = TopLevelRoute.Auth, + topLevelRoutes = setOf( + TopLevelRoute.Auth, + TopLevelRoute.Profile, + TopLevelRoute.Settings + ) + ) + + LaunchedEffect(navigationState.topLevelRoute) { + val currentStack = navigationState.backStacks[navigationState.topLevelRoute] + val currentRoute = currentStack?.last() + currentRoute?.let { route -> + analytics.logScreenView( + screenName = route::class.simpleName ?: "Unknown", + screenClass = "MainActivity" + ) + } + } + + val entryProvider = entryProvider { + authGraph(/* navigator */) + profileGraph(/* navigator */) + settingsGraph(/* navigator */) + } + + NavDisplay( + entries = navigationState.toEntries(entryProvider), + onBack = { navigator.goBack() } + ) +} +``` + +### Capturing UI State (Delegation) + +Use delegation to standardize custom keys and logs across ViewModels. See `references/kotlin-delegation.md` for more patterns. + +```kotlin +interface CrashlyticsStateLogger { + fun logUiState(key: String, value: String) + fun logAction(message: String) +} + +class FirebaseCrashlyticsStateLogger @Inject constructor( + private val crashlytics: FirebaseCrashlytics +) : CrashlyticsStateLogger { + override fun logUiState(key: String, value: String) { + crashlytics.setCustomKey(key, value) + } + + override fun logAction(message: String) { + crashlytics.log(message) + } +} + +@HiltViewModel +class AuthViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, + crashReporter: CrashReporter, // No private - delegated only + logger: CrashlyticsStateLogger // No private - delegated only +) : ViewModel(), + CrashReporter by crashReporter, + CrashlyticsStateLogger by logger { + + fun onRoleSelected(role: String) { + logUiState("auth_role", role) + logAction("Auth role selected: $role") + } + + fun onLoginFailed(error: Throwable) { + recordException( + error, + mapOf("action" to "login", "screen" to "auth") + ) + } +} +``` + +### Non-fatal Exceptions in Coroutines + +```kotlin +val crashHandler = CoroutineExceptionHandler { _, exception -> + Firebase.crashlytics.recordException(exception) +} + +viewModelScope.launch(crashHandler) { + repository.refreshSession() +} +``` + +## Wiring in the App Module + +Use DI bindings to switch providers without changing feature code. + +### Using Firebase Crashlytics + +```kotlin +@Module +@InstallIn(SingletonComponent::class) +abstract class CrashReporterModule { + @Binds + abstract fun bindCrashReporter( + impl: FirebaseCrashReporter + ): CrashReporter + + @Provides + @Singleton + fun provideFirebaseCrashlytics(): FirebaseCrashlytics = + FirebaseCrashlytics.getInstance() +} +``` + +Apply the Firebase convention plugin in `app/build.gradle.kts`: +```kotlin +plugins { + alias(libs.plugins.app.firebase) +} +``` + +### Using Sentry + +```kotlin +@Module +@InstallIn(SingletonComponent::class) +abstract class CrashReporterModule { + @Binds + abstract fun bindCrashReporter( + impl: SentryCrashReporter + ): CrashReporter +} +``` + +Apply the Sentry convention plugin in `app/build.gradle.kts`: +```kotlin +plugins { + alias(libs.plugins.app.sentry) +} +``` + +**Switching providers:** Simply change the binding and convention plugin. Feature modules remain unchanged. + +## Rules + +Required: +- Initialize the provider exactly once, in the `app` module. +- Use `Sentry.withScope` for per-call tags. `Sentry.configureScope` mutates Global Scope on main thread and Thread Scope on background threads - avoid it for one-off context. +- Sample tracing / profiling in production (`tracesSampleRate`, `profilesSampleRate` < 1.0) when traffic is non-trivial. +- Record non-fatals only for actionable failures (network failures, parse errors, recoverable exceptions). +- Pair crash data with analytics events on user-facing flows to surface pre-crash context. +- Upload ProGuard/R8 mapping files on every release build; keep `SourceFile` and `LineNumberTable`. + +Forbidden: +- PII (email, phone, raw user objects, auth tokens) in tags, breadcrumbs, or messages. +- Recording every method call or coroutine launch as a breadcrumb. +- Letting the same exception be logged at multiple layers without context to dedupe it. + +## ProGuard/R8 Mapping Upload + +Both providers require mapping file upload for symbolicated crashes in release builds. See [gradle-setup.md](/references/gradle-setup.md#r8--proguard-configuration) for R8 build configuration and `assets/proguard-rules.pro.template` for all keep rules. + +### Firebase Crashlytics + +The Firebase Crashlytics Gradle plugin automatically uploads mapping files during the build. Apply the convention plugin and enable minification: + +```kotlin +plugins { + alias(libs.plugins.app.firebase) +} +``` + +**Important**: To ensure your stack traces include exact line numbers, you must keep the `SourceFile` and `LineNumberTable` attributes. This is already included in the provided `assets/proguard-rules.pro.template`. + +### Sentry + +The Sentry Gradle plugin handles mapping upload automatically. Configure it in the app module: + +```kotlin +plugins { + alias(libs.plugins.app.sentry) +} + +sentry { + includeSourceContext.set(true) + autoInstallation.sentryVersion.set(libs.versions.sentry.get()) + includeProguardMapping.set(true) + autoUploadProguardMapping.set(true) + org.set("your-org") + projectName.set("your-project") + authToken.set(System.getenv("SENTRY_AUTH_TOKEN")) +} + +## Breadcrumbs + +Record only: +- User navigation (`category = "navigation"`). +- Discrete UI interactions (`category = "ui.click"`, with stable element id in `data`). +- Auth / session / network state transitions (`category = "state"`). + +Do not record: +- Internal implementation events (coroutine launches, dispatcher hops, lifecycle ticks). +- Per-method-call traces (`getUserId() called`). +- Raw object dumps or anything containing PII. + +Reference shape: + +```kotlin +Sentry.addBreadcrumb(Breadcrumb().apply { + message = "User clicked logout button" + category = "ui.click" + level = SentryLevel.INFO + data = mapOf("button_id" to "logout_btn") +}) +``` + +Both providers auto-track Jetpack Compose navigation when `androidx.navigation` is on the classpath. Add custom breadcrumbs in the app-level navigation coordinator only. + +## Network Request Tracking + +### Failed Network Requests + +Track failed API calls to understand network-related crashes. + +```kotlin +// In OkHttp interceptor or repository layer +class AuthRepository @Inject constructor( + crashReporter: CrashReporter // No private - delegated only +) : CrashReporter by crashReporter { + suspend fun login(email: String, password: String): Result { + return try { + val response = authApi.login(email, password) + Result.success(response) + } catch (e: IOException) { + // Network error + log("Network error during login: ${e.message}") + recordException(e, mapOf( + "endpoint" to "auth/login", + "error_type" to "network" + )) + Result.failure(e) + } catch (e: HttpException) { + // HTTP error (4xx, 5xx) + log("HTTP error during login: ${e.code()}") + recordException(e, mapOf( + "endpoint" to "auth/login", + "status_code" to e.code().toString(), + "error_type" to "http" + )) + Result.failure(e) + } + } +} +``` + +For Sentry + OkHttp use the `sentry-okhttp` integration for automatic network breadcrumbs: https://docs.sentry.io/platforms/android/integrations/okhttp/ + +## Testing Crash Reporting + +### Test Crashes in Development + +Add a debug-only method to test crash reporting: + +```kotlin +@HiltViewModel +class DebugViewModel @Inject constructor( + crashReporter: CrashReporter // No private - delegated only +) : ViewModel(), CrashReporter by crashReporter { + + // Only available in debug builds + fun testCrash() { + if (BuildConfig.DEBUG) { + // Test non-fatal exception + recordException( + RuntimeException("Test crash from debug menu"), + mapOf("test" to "true", "source" to "debug_menu") + ) + + // Test fatal crash (uncomment to test) + // throw RuntimeException("Test fatal crash") + } + } +} + +// In your debug/settings screen +@Composable +fun DebugScreen(viewModel: DebugViewModel = hiltViewModel()) { + Button(onClick = { viewModel.testCrash() }) { + Text("Test Non-Fatal Crash") + } +} +``` + +### Disable Crash Reporting in Debug (Optional) + +To avoid polluting production data with debug crashes: + +**Firebase:** +```xml + + + +``` + +Enable at runtime: +```kotlin +if (BuildConfig.DEBUG) { + FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(false) +} +``` + +**Sentry:** +```kotlin +SentryAndroid.init(this) { options -> + options.environment = if (BuildConfig.DEBUG) "debug" else "production" + // Optional: disable entirely in debug + options.dsn = if (BuildConfig.DEBUG) "" else "YOUR_DSN_HERE" +} +``` + +## Data Scrubbing (Privacy/GDPR) + +Remove sensitive information before sending to crash reporters. + +### Built-in Scrubbing (Sentry) + +Sentry automatically scrubs common PII fields: + +```kotlin +SentryAndroid.init(this) { options -> + // Disable automatic PII scrubbing if you need custom control + options.sendDefaultPii = false + + // Add custom data scrubbing + options.setBeforeSend { event, hint -> + // Scrub email addresses from exception messages + event.message?.message = event.message?.message?.replace( + Regex("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"), + "[REDACTED_EMAIL]" + ) + + // Strip tags that can carry PII + event.removeTag("user_email") + event.removeExtra("raw_user_data") + + event + } +} +``` + +### Custom Scrubbing for Both Providers + +Implement scrubbing in your `CrashReporter` wrapper: + +```kotlin +class PrivacyAwareCrashReporter @Inject constructor( + crashReporter: CrashReporter // No private - delegated only +) : CrashReporter by crashReporter { + + private val emailRegex = Regex("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}") + private val sensitiveKeys = setOf("password", "token", "secret", "key", "auth") + + override fun recordException( + throwable: Throwable, + context: Map + ) { + // Scrub context + val scrubbedContext = context.filterKeys { key -> + !sensitiveKeys.any { key.contains(it, ignoreCase = true) } + }.mapValues { (_, value) -> + value.replace(emailRegex, "[REDACTED_EMAIL]") + } + + // Use super to call the delegated implementation + super.recordException(throwable, scrubbedContext) + } + + override fun log(message: String) { + val scrubbedMessage = message.replace(emailRegex, "[REDACTED_EMAIL]") + super.log(scrubbedMessage) + } +} +``` + +Wire it in DI: + +```kotlin +@Module +@InstallIn(SingletonComponent::class) +abstract class CrashReporterModule { + @Binds + abstract fun bindCrashReporter( + impl: PrivacyAwareCrashReporter + ): CrashReporter + + @Provides + @Singleton + fun providePrivacyAwareCrashReporter( + @Named("raw") rawReporter: CrashReporter + ): PrivacyAwareCrashReporter = PrivacyAwareCrashReporter(rawReporter) + + @Provides + @Singleton + @Named("raw") + fun provideRawCrashReporter(): CrashReporter = FirebaseCrashReporter( + FirebaseCrashlytics.getInstance() + ) +} +``` + +## Gradle & Setup Guidance + +- Keep SDK dependencies in the version catalog (`assets/libs.versions.toml.template`). +- Follow `references/gradle-setup.md` for plugin configuration patterns. +- For provider-specific setup, follow the official docs: + - Sentry Android install and configuration: https://docs.sentry.io/platforms/android/ + - Sentry manual setup + plugin details: https://docs.sentry.io/platforms/android/manual-setup/ + - Firebase Crashlytics setup: https://firebase.google.com/docs/crashlytics/android/get-started + - Crashlytics + Compose example: https://firebase.blog/posts/2022/06/adding-crashlytics-to-jetpack-compose-app/ + - Sentry + Compose integration: https://docs.sentry.io/platforms/android/integrations/jetpack-compose/ diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/references/dependencies.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/dependencies.md new file mode 100644 index 000000000..3693b915c --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/dependencies.md @@ -0,0 +1,253 @@ +# Dependencies + +Required: every dependency goes through `assets/libs.versions.toml.template`. Do not hard-code coordinates or versions in module `build.gradle.kts`. + +## Version Catalog Source of Truth +Always check `assets/libs.versions.toml.template` before adding or changing dependencies. + +### Rules +1. **Reuse existing catalog entries** before inventing new coordinates +2. **If a dependency is missing**, add it to `libs.versions.toml` following the same grouping and naming conventions +3. **Keep versions centralized** in the `[versions]` section; reference them by `version.ref` +4. **Use bundles** when multiple libraries ship together (e.g., Compose, Navigation, Testing) +5. **Use platform dependencies** (BOMs) for coordinated version management (Compose, Firebase) + +## Dependency Selection + +| Concern | Use | Avoid / Only-if-migrating | +|----------------------|------------------------------------------------------------------------------|----------------------------------------------------| +| REST networking | Retrofit + OkHttp + `retrofit2-kotlinx-serialization-converter` | Ktor Client (reserve for Kotlin Multiplatform) | +| Image loading | Coil 3.x (`coil-compose` + `coil-network-okhttp`) | Glide (only when migrating heavy View-based usage) | +| JSON serialization | `kotlinx-serialization` | Gson (only with deep existing investment) | +| Dependency injection | Hilt (required) | Manual DI, Koin | +| AndroidX | `-ktx` artifacts (`core-ktx`, `lifecycle-runtime-ktx`, …) | `com.android.support.*` (deprecated) | + +Hilt module patterns, scopes, and anti-patterns: [architecture.md → Dependency Injection Setup](/references/architecture.md#dependency-injection-setup). + +### Room 3 +Required artifacts: `androidx.room3:room3-runtime`, `sqlite-bundled`, KSP `room3-compiler` (see version catalog). DAOs are coroutine-first (`suspend`, `Flow`). Add `room3-paging` only when a DAO returns `PagingSource`; `room3-testing` only for instrumented DB tests. + +### Paging 3 test artifact +Use `androidx.paging:paging-testing` on test source sets only (`testImplementation(libs.androidx.paging.testing)` from the version catalog). Keep the `paging` version ref aligned with `paging-runtime` / `paging-compose`. Align snapshot and scroll test code with [Test your Paging implementation](https://developer.android.com/topic/libraries/architecture/paging/test). + +## Version Strategy + +### Stability Requirements + +**Production apps:** +- Use **stable** versions only (e.g., `1.0.0`) for libraries that offer a stable channel +- Avoid alpha/beta/RC for **Hilt** and **Coroutines** in production +- **Room 3:** Ship **stable** `androidx.room3` builds from [Room 3 releases](https://developer.android.com/jetpack/androidx/releases/room3). Preview builds require pinning the exact version from that page and scheduling the upgrade to stable. + +**Experimental projects:** +- Can use alpha/beta for evaluation +- Document experimental versions clearly + +### Pinned alpha required for feature parity + +These catalog entries stay on alpha until a feature-equivalent stable release ships. Replace each pin with the stable release as soon as one exists. + +- `room3` - no stable Room 3 release yet; track [Room 3 releases](https://developer.android.com/jetpack/androidx/releases/room3) and bump on every alpha tick. +- `materialAdaptive` - 1.2.0 stable does not ship `material3-adaptive-navigation3`; the bridge artifact only exists on the 1.3 alpha line. +- `androidxBiometric` - 1.1.0 stable lacks `BiometricPrompt` content view, logo, and `registerForAuthenticationResult()`; the alpha line is the only source for those APIs. +- `tracing` - `tracing-wire-android` (Perfetto in-process tracing) is 2.x-only; the 1.3 stable line cannot be substituted. +- `detekt` - 2.x is a new artifact group (`dev.detekt`); 1.23.x lives at `io.gitlab.arturbosch.detekt` and would require swapping coordinates. +- `screenshot` - Compose Preview Screenshot Testing plugin line; still pre-stable on many stacks - bump only from Android Studio / AGP release notes and re-run `screenshotTest` validation after every pin change. Roborazzi is optional visual-regression tooling; pin `io.github.takahirom.roborazzi` artifacts in the catalog only when the project adopts it ([testing.md → Preview Screenshot Testing vs Roborazzi](/references/testing.md#preview-screenshot-testing-vs-roborazzi)). + +### Visual regression tooling (catalog) + +| Tooling | Catalog | Rule | +|------------------------------------|---------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------| +| Compose Preview Screenshot Testing | `screenshot` plugin + `screenshot-validation-api` from `assets/libs.versions.toml.template` | Keep in `screenshotTest`; align pins with Studio docs | +| Roborazzi | Not in the template catalog until a project adds explicit coordinates | Add `io.github.takahirom.roborazzi` modules only when Roborazzi is the chosen stack | + +### Version update cadence + +**Security patches:** + +- Update immediately for CVEs +- Check dependency-check tools or GitHub security alerts + +**Feature updates:** + +- Update when needed for specific features +- Test thoroughly in feature branches + +**Breaking changes:** + +- Update during planned refactoring windows +- Review migration guides first + +### Version Conflict Resolution + +**Use platform dependencies (BOMs) for coordinated versioning:** + +```kotlin +dependencies { + // Compose BOM manages all Compose library versions + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.material3) // Version from BOM + + // Firebase BOM + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.crashlytics) // Version from BOM + implementation(libs.firebase.analytics) +} +``` + +**Force specific versions when needed:** + +```kotlin +configurations.all { + resolutionStrategy { + force("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + } +} +``` + +## Kotlin & Compose Compiler Compatibility + +**Critical**: Kotlin and Compose compiler versions must be compatible. Mismatches cause compile errors. + +Current template versions: +- Kotlin: `2.3.21` +- Compose BOM: `2026.04.01` +- Compose Compiler: Managed by `kotlin-compose` plugin + +The `kotlin-compose` plugin (formerly `compose-compiler`) is now part of Kotlin and automatically matches the Kotlin version. + +**When updating Kotlin:** +1. Check Compose compatibility: https://developer.android.com/jetpack/androidx/releases/compose-kotlin +2. Update both `kotlin` and `compose-bom` versions together +3. Pick the matching KSP line on Maven Central or [KSP releases](https://github.com/google/ksp/releases); catalog `ksp` may use a `kotlinVersion-kspToolVersion` string or a standalone KSP release (patch digits need not match Kotlin) +4. Run `./gradlew help` before committing + +## Platform Dependencies (BOMs) + +BOMs (Bill of Materials) manage versions of related libraries, ensuring compatibility. + +**Use BOMs when:** + +```kotlin +// Compose BOM - manages all androidx.compose.* versions +implementation(platform(libs.androidx.compose.bom)) + +// Firebase BOM - manages all firebase.* versions +implementation(platform(libs.firebase.bom)) +``` + +**Don't specify versions for BOM-managed dependencies:** + +```kotlin +// CORRECT: version from BOM +implementation(libs.androidx.compose.ui) + +// WRONG: explicit version overrides BOM +implementation("androidx.compose.ui:ui:1.7.0") +``` + +## Testing Dependencies + +### Test Scopes + +**`testImplementation`** - Unit tests (JVM) +- `junit`, `kotlin-test`, `mockk`, `kotlinx-coroutines-test`, `turbine`, `google-truth` + +**`androidTestImplementation`** - Instrumented tests (Android device/emulator) +- `androidx-junit`, `androidx-espresso-core`, `androidx-compose-ui-test-junit4` + +**`debugImplementation`** - Debug builds only +- `leakcanary-android`, `androidx-compose-ui-tooling`, `androidx-compose-ui-test-manifest` + +### Test Bundles + +Use `libs.bundles.unit-test` and `libs.bundles.android-test` for consistent test dependencies across modules. +These are defined in `assets/libs.versions.toml.template`. + +## Build Performance Considerations + +### `api` vs `implementation` + +**`implementation`:** default for module-private dependencies — hides transitives from downstream compilation units and limits recompilation when internals change. + +**`api`:** dependency types appear in the module's public API (signatures, public properties), e.g. `core:domain` exporting `Flow` from `kotlinx-coroutines`. + +```kotlin +// core:domain/build.gradle.kts +dependencies { + // Coroutines types are in public API (suspend, Flow) + api(libs.kotlinx.coroutines.core) + + // Inject is only used internally + implementation(libs.java.inject) +} +``` + +### Annotation Processing: KSP > Kapt + +**Required: KSP (Kotlin Symbol Processing).** +- 2x faster than kapt +- **Room 3 is KSP-only** (no kapt/Java annotation processing for Room) +- Hilt supports KSP +- Catalog `kotlin` and `ksp` are a **tested pair**, not identical patch strings. KSP ships on its own schedule; choose the highest KSP release that supports the catalog Kotlin version, then verify `./gradlew help`. + +**Migrate from kapt to KSP:** + +```kotlin +// Old +plugins { + id("kotlin-kapt") +} + +kapt { + correctErrorTypes = true +} + +dependencies { + kapt(libs.hilt.compiler) + kapt("androidx.room:room-compiler:") // Room 2.x: pin locally; not in template catalog +} + +// New +plugins { + id("com.google.devtools.ksp") version "2.3.7" +} + +dependencies { + ksp(libs.hilt.compiler) + ksp(libs.room3.compiler) + // Room 3 also requires a SQLite driver at runtime, e.g. sqlite-bundled (see app.android.room convention) +} +``` + +## ProGuard/R8 Considerations + +Use `assets/proguard-rules.pro.template` as the source of truth for all keep rules. It includes rules for every library in the version catalog (Retrofit, kotlinx-serialization, Room 3, OkHttp, Hilt, SQLCipher, etc.). + +Copy the template to `app/proguard-rules.pro` and adjust `com.example.*` package names. See [gradle-setup.md](/references/gradle-setup.md#r8--proguard-configuration) for build configuration. + +## Adding a New Dependency + +Checklist (in order, fail-fast): + +- [ ] Confirm it is not already in `assets/libs.versions.toml.template`. +- [ ] Stable channel exists (Hilt/Coroutines/Retrofit/Coil must be stable). +- [ ] Actively maintained (commit/release within last 12 months). +- [ ] License is Apache 2.0 or MIT (or pre-approved equivalent). +- [ ] APK size impact measured for app modules. +- [ ] Add `[versions]` + `[libraries]` entries in `libs.versions.toml` (and a bundle if used together). +- [ ] Reference via `libs..` in module `build.gradle.kts` - never raw coordinates. +- [ ] Add ProGuard/R8 keep rules to `assets/proguard-rules.pro.template` if the library uses reflection or annotations. +- [ ] Run `./gradlew assembleDebug testDebugUnitTest` before commit. + +Example wiring after the catalog entries are added: + +```kotlin +dependencies { + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.android) +} +``` + +Convention-plugin and module wiring details: [gradle-setup.md](/references/gradle-setup.md). diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/references/design-patterns.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/design-patterns.md new file mode 100644 index 000000000..95b2b5793 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/design-patterns.md @@ -0,0 +1,1750 @@ +# Design Patterns (Android-Focused) + +Pattern catalog for feature, module, business-logic, and utility design. Aligned with [architecture.md](/references/architecture.md) and [modularization.md](/references/modularization.md). Cache, conflict-resolution, and sync patterns live in [android-data-sync.md](/references/android-data-sync.md). + +## Table of Contents +1. [Principles](#principles) +2. [Architectural Patterns](#architectural-patterns) +3. [Creational Patterns](#creational-patterns) +4. [Structural Patterns](#structural-patterns) +5. [Behavioral Patterns](#behavioral-patterns) +6. [Kotlin-Specific Patterns](#kotlin-specific-patterns) +7. [Anti-Patterns to Avoid](#anti-patterns-to-avoid) + +## Principles + +Required: +- Use composition and delegation over inheritance ([kotlin-delegation.md](/references/kotlin-delegation.md)). +- Keep patterns local to the layer they belong to (UI / Domain / Data). +- Avoid framework-heavy base classes; keep components testable. +- Use DI scopes for app-wide lifetimes; never roll manual singletons. +- Start simple; add a pattern only when concrete pain forces it. +- Kotlin-first: sealed classes, data classes, delegation, coroutines, `Flow`. + +## Architectural Patterns + +### MVVM (Model-View-ViewModel) +- **When**: All feature modules (this is our base architecture). +- **Android use**: ViewModel holds `StateFlow`, Composables observe and render. +- Full repository / state-flow contract: [architecture.md](/references/architecture.md). + +```kotlin +// Feature: Auth +@Immutable +sealed interface AuthUiState { + data object Loading : AuthUiState + data class LoginForm(val email: String, val password: String, val error: String?) : AuthUiState + data class Success(val user: User) : AuthUiState +} + +sealed interface AuthAction { + data class EmailChanged(val email: String) : AuthAction + data class PasswordChanged(val password: String) : AuthAction + data object LoginClicked : AuthAction +} + +@HiltViewModel +class AuthViewModel @Inject constructor( + private val authRepository: AuthRepository +) : ViewModel() { + private val _uiState = MutableStateFlow(AuthUiState.LoginForm("", "", null)) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onAction(action: AuthAction) { + when (action) { + is AuthAction.EmailChanged -> updateEmail(action.email) + is AuthAction.PasswordChanged -> updatePassword(action.password) + AuthAction.LoginClicked -> login() + } + } + + private fun login() { + viewModelScope.launch { + _uiState.update { AuthUiState.Loading } + authRepository.login(email, password).fold( + onSuccess = { _uiState.update { AuthUiState.Success(it.user) } }, + onFailure = { _uiState.update { AuthUiState.LoginForm(email, password, it.message) } } + ) + } + } +} + +@Composable +fun AuthRoute( + viewModel: AuthViewModel = hiltViewModel(), + authNavigator: AuthNavigator +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + AuthScreen( + uiState = uiState, + onAction = viewModel::onAction + ) +} +``` + +### Repository Pattern +- **When**: All data access (single source of truth). +- **Android use**: Hide local/remote/cache complexity behind a clean interface. +- Implementation contract: [architecture.md](/references/architecture.md). + +```kotlin +// core/domain +@Stable +interface AuthRepository { + suspend fun login(email: String, password: String): Result + fun observeAuthState(): Flow +} + +// core/data +internal class AuthRepositoryImpl @Inject constructor( + private val localDataSource: AuthLocalDataSource, + private val remoteDataSource: AuthRemoteDataSource +) : AuthRepository { + override suspend fun login(email: String, password: String): Result = + try { + val token = remoteDataSource.login(email, password) + localDataSource.saveAuthToken(token) + Result.success(token) + } catch (e: IOException) { + Result.failure(AuthError.NetworkError("No internet connection", e)) + } + + override fun observeAuthState(): Flow = + localDataSource.observeAuthToken().map { token -> + if (token != null) AuthState.Authenticated else AuthState.Unauthenticated + } +} +``` + +## Creational Patterns + +### Singleton +- **When**: You need a single, app-wide instance. +- **Android use**: Use DI with `@Singleton` for repositories, loggers, and crash reporters. +- Forbidden: `object` singletons holding Android dependencies. Use Hilt scopes instead. + +```kotlin +// WRONG: Holds context statically +object BadAnalytics { + private lateinit var context: Context + + fun init(context: Context) { + this.context = context.applicationContext + } +} + +// CORRECT: DI-managed singleton +@Module +@InstallIn(SingletonComponent::class) +abstract class DataModule { + @Binds + @Singleton + abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository +} + +// Usage in ViewModel +@HiltViewModel +class AuthViewModel @Inject constructor( + private val authRepository: AuthRepository // Always the same instance +) : ViewModel() +``` + +### Factory Method +- **When**: The concrete class should vary by environment or runtime. +- **Android use**: `ViewModelProvider.Factory`, `WorkManager` factories, Retrofit service creation. +- Place factory interfaces in `core/domain` or `core/common`. + +```kotlin +// core/domain +interface DataSourceFactory { + fun createAuthDataSource(): AuthDataSource +} + +// core/data - Production +class RemoteDataSourceFactory @Inject constructor( + private val retrofit: Retrofit +) : DataSourceFactory { + override fun createAuthDataSource(): AuthDataSource = + retrofit.create(AuthDataSource::class.java) +} + +// core/data - Testing +class FakeDataSourceFactory : DataSourceFactory { + override fun createAuthDataSource(): AuthDataSource = + FakeAuthDataSource() +} + +// DI setup +@Module +@InstallIn(SingletonComponent::class) +abstract class DataSourceModule { + @Binds + abstract fun bindFactory(impl: RemoteDataSourceFactory): DataSourceFactory +} +``` + +### Abstract Factory +- **When**: You need families of related implementations. +- **Android use**: Swap entire provider families (Crashlytics vs Sentry, Firebase vs custom). +- Use for build-variant swaps and test doubles. + +```kotlin +// core/domain +interface CrashReporterFactory { + fun createCrashReporter(): CrashReporter + fun createAnalytics(): Analytics +} + +// core/data - Firebase family +class FirebaseFactory @Inject constructor() : CrashReporterFactory { + override fun createCrashReporter(): CrashReporter = FirebaseCrashReporter() + override fun createAnalytics(): Analytics = FirebaseAnalytics() +} + +// core/data - Sentry family +class SentryFactory @Inject constructor() : CrashReporterFactory { + override fun createCrashReporter(): CrashReporter = SentryCrashReporter() + override fun createAnalytics(): Analytics = SentryAnalytics() +} + +// DI - Choose based on build variant +@Module +@InstallIn(SingletonComponent::class) +object ReporterModule { + @Provides + @Singleton + fun provideFactory(): CrashReporterFactory = + if (BuildConfig.USE_FIREBASE) FirebaseFactory() else SentryFactory() +} +``` + +### Builder +- **When**: Complex object configuration needs clarity or optional steps. +- **Android use**: `OkHttpClient.Builder`, `Retrofit.Builder`, custom config builders. +- Output must be immutable; configuration belongs in DI modules. + +```kotlin +// core/network +class ApiClientBuilder @Inject constructor( + private val loggingInterceptor: HttpLoggingInterceptor, + private val authInterceptor: AuthInterceptor +) { + private var baseUrl: String = "" + private var timeout: Long = 30L + private var retryOnConnectionFailure: Boolean = true + + fun baseUrl(url: String) = apply { this.baseUrl = url } + fun timeout(seconds: Long) = apply { this.timeout = seconds } + fun disableRetry() = apply { this.retryOnConnectionFailure = false } + + fun build(): OkHttpClient = OkHttpClient.Builder() + .addInterceptor(authInterceptor) + .addInterceptor(loggingInterceptor) + .connectTimeout(timeout, TimeUnit.SECONDS) + .retryOnConnectionFailure(retryOnConnectionFailure) + .build() +} + +// Usage in DI +@Provides +@Singleton +fun provideOkHttpClient(builder: ApiClientBuilder): OkHttpClient = + builder + .baseUrl(BuildConfig.API_URL) + .timeout(60L) + .build() +``` + +### Prototype +- **When**: Cloning is cheaper than new construction. +- **Android use**: Copying immutable UI models (`data class.copy`) for state updates. +- Apply for `UiState` updates and form-state mutation. + +```kotlin +@Immutable +data class RegisterFormState( + val email: String = "", + val password: String = "", + val confirmPassword: String = "", + val agreedToTerms: Boolean = false, + val errors: Map = emptyMap() +) + +@HiltViewModel +class RegisterViewModel @Inject constructor() : ViewModel() { + private val _formState = MutableStateFlow(RegisterFormState()) + val formState: StateFlow = _formState.asStateFlow() + + fun onEmailChanged(email: String) { + _formState.update { it.copy(email = email) } // Prototype pattern via copy() + } + + fun onPasswordChanged(password: String) { + _formState.update { it.copy(password = password) } + } + + fun onAgreedToTermsChanged(agreed: Boolean) { + _formState.update { it.copy(agreedToTerms = agreed) } + } +} +``` + +## Structural Patterns + +### Adapter +- **When**: You need to reconcile mismatched interfaces (DTOs → Domain models). +- **Android use**: Mapping network DTOs to domain models, database entities to domain. +- Adapters live in `core/data`; never expose DTOs above the data layer. + +```kotlin +// core/network - DTO (from API) +@Serializable +data class NetworkUser( + @SerialName("user_id") val userId: String, + @SerialName("email_address") val emailAddress: String, + @SerialName("full_name") val fullName: String +) + +// core/domain - Domain model +@Immutable +data class User( + val id: String, + val email: String, + val name: String +) + +// core/data - Adapter +class UserAdapter { + fun toDomain(network: NetworkUser): User = User( + id = network.userId, + email = network.emailAddress, + name = network.fullName + ) + + fun toNetwork(domain: User): NetworkUser = NetworkUser( + userId = domain.id, + emailAddress = domain.email, + fullName = domain.name + ) +} +``` + +### Bridge +- **When**: You want abstraction to vary independently of implementation. +- **Android use**: `Navigator` interfaces in features with app-level implementations. +- Features must not import `NavController` or sibling features; route through their `Navigator` interface. + +```kotlin +// feature/auth - Abstraction +interface AuthNavigator { + fun navigateToHome() + fun navigateToRegister() + fun navigateBack() +} + +// feature/auth - Feature doesn't know about NavController +@Composable +fun LoginScreen( + onLoginSuccess: () -> Unit, // Provided by abstraction + onRegisterClick: () -> Unit, + modifier: Modifier = Modifier +) { + Button(onClick = onRegisterClick) { + Text("Create Account") + } +} + +// app - Implementation (Bridge) +class AppAuthNavigator( + private val navigator: Navigator +) : AuthNavigator { + override fun navigateToHome() { + navigator.navigate(TopLevelRoute.Home) + } + + override fun navigateToRegister() { + navigator.navigate(AuthDestination.Register) + } + + override fun navigateBack() { + navigator.goBack() + } +} +``` + +### Composite +- **When**: You need tree-like structures with uniform treatment. +- **Android use**: Navigation graphs, UI component trees, menu structures. +- Use for adaptive UI on tablets/foldables and recursive menu trees. + +```kotlin +// core/ui - Component interface +sealed interface NavigationItem { + val id: String + val label: String + val iconRes: Int +} + +// Leaf node +@Immutable +data class NavScreen( + override val id: String, + override val label: String, + override val iconRes: Int, + val route: String +) : NavigationItem + +// Composite node +@Immutable +data class NavGroup( + override val id: String, + override val label: String, + override val iconRes: Int, + val children: List +) : NavigationItem + +// Usage +val navigationTree = listOf( + NavScreen("home", "Home", R.drawable.ic_home, "home"), + NavGroup( + "settings", + "Settings", + R.drawable.ic_settings, + children = listOf( + NavScreen("profile", "Profile", R.drawable.ic_person, "settings/profile"), + NavScreen("privacy", "Privacy", R.drawable.ic_lock, "settings/privacy"), + NavScreen("about", "About", R.drawable.ic_info, "settings/about") + ) + ) +) + +@Composable +fun NavigationMenu(items: List) { + items.forEach { item -> + when (item) { + is NavScreen -> NavigationButton(item) + is NavGroup -> { + NavigationGroupHeader(item) + NavigationMenu(item.children) // Recursive composite + } + } + } +} +``` + +### Decorator +- **When**: Add behavior without modifying the original type. +- **Android use**: OkHttp interceptors, Compose `Modifier` chains, logging decorators. +- Each decorator addresses one cross-cutting concern; stack via Kotlin `by` delegation. + +```kotlin +// core/domain - Base interface (see references/crashlytics.md → "Provider-Agnostic Interface") +interface CrashReporter { + fun recordException(throwable: Throwable, context: Map = emptyMap()) + fun log(message: String) +} + +// core/data - Base implementation +class FirebaseCrashReporter @Inject constructor( + private val crashlytics: FirebaseCrashlytics +) : CrashReporter { + override fun recordException(throwable: Throwable, context: Map) { + crashlytics.recordException(throwable) + } + + override fun log(message: String) { + crashlytics.log(message) + } +} + +// core/data - Decorator (adds logging) +class LoggingCrashReporter( + crashReporter: CrashReporter, // No private - delegated only + private val logger: Logger +) : CrashReporter by crashReporter { + override fun recordException(throwable: Throwable, context: Map) { + logger.d("Recording exception: ${throwable.message}") + super.recordException(throwable, context) + } +} + +// core/data - Another decorator (adds privacy scrubbing) +class PrivacyAwareCrashReporter( + crashReporter: CrashReporter // No private - delegated only +) : CrashReporter by crashReporter { + override fun recordException(throwable: Throwable, context: Map) { + val scrubbedContext = context.filterKeys { it !in SENSITIVE_KEYS } + super.recordException(throwable, scrubbedContext) + } + + companion object { + private val SENSITIVE_KEYS = setOf("password", "token", "apiKey") + } +} + +// DI - Stack decorators +@Provides +@Singleton +fun provideCrashReporter( + firebase: FirebaseCrashReporter, + logger: Logger +): CrashReporter = + PrivacyAwareCrashReporter( + LoggingCrashReporter(firebase, logger) + ) +``` + +### Delegation +- **When**: You want to reuse behavior without inheritance. +- **Android use**: Delegating interface implementations, ViewModel delegation, repository delegation. +- Use Kotlin `by`. Patterns and pitfalls: [kotlin-delegation.md](/references/kotlin-delegation.md). + +```kotlin +// core/domain +interface CrashlyticsStateLogger { + fun logUiState(key: String, value: Any) + fun logAction(action: String) +} + +// core/data +class CrashlyticsStateLoggerImpl @Inject constructor( + private val crashlytics: FirebaseCrashlytics +) : CrashlyticsStateLogger { + override fun logUiState(key: String, value: Any) { + crashlytics.setCustomKey(key, value.toString()) + } + + override fun logAction(action: String) { + crashlytics.log("Action: $action") + } +} + +// feature/auth - ViewModel uses delegation +@HiltViewModel +class AuthViewModel @Inject constructor( + private val authRepository: AuthRepository, + logger: CrashlyticsStateLogger +) : ViewModel(), CrashlyticsStateLogger by logger { + + fun onLoginClicked() { + logAction("Login clicked") // Delegated method + logUiState("screen", "login") // Delegated method + // ... login logic + } +} +``` + +### Facade +- **When**: Provide a simplified API to complex subsystems. +- **Android use**: Repositories hiding local/remote/cache details. +- Repository is the only public entry to a data subsystem. Contract: [architecture.md](/references/architecture.md). + +```kotlin +// core/data - Complex subsystems (hidden) +internal interface AuthLocalDataSource { + suspend fun saveAuthToken(token: String) + suspend fun getAuthToken(): String? + fun observeAuthToken(): Flow +} + +internal interface AuthRemoteDataSource { + suspend fun login(email: String, password: String): AuthToken + suspend fun refreshToken(token: String): AuthToken +} + +internal interface AuthCacheDataSource { + fun cacheUser(user: User) + fun getCachedUser(): User? +} + +// core/data - Facade (simple public API) +@Singleton +class AuthRepositoryImpl @Inject constructor( + private val local: AuthLocalDataSource, + private val remote: AuthRemoteDataSource, + private val cache: AuthCacheDataSource +) : AuthRepository { + + // Simple API hides complexity + override suspend fun login(email: String, password: String): Result = + try { + val token = remote.login(email, password) + local.saveAuthToken(token.value) + cache.cacheUser(token.user) + Result.success(token) + } catch (e: Exception) { + Result.failure(e) + } + + override fun observeAuthState(): Flow = + local.observeAuthToken().map { token -> + if (token != null) { + val user = cache.getCachedUser() ?: return@map AuthState.Loading + AuthState.Authenticated(user) + } else { + AuthState.Unauthenticated + } + } +} +``` + +### Flyweight +- **When**: You need to reduce memory by sharing common state. +- **Android use**: Image loading caches, shared UI resources, reused `Painter`s. +- Forbidden: instantiating heavy objects inside a `@Composable`. Cache via `remember` or `by lazy`. + +```kotlin +// core/ui - Icon resource management +object IconResources { + fun getIconRes(name: String): Int = + when (name) { + "home" -> R.drawable.ic_home + "profile" -> R.drawable.ic_person + "settings" -> R.drawable.ic_settings + else -> R.drawable.ic_question_mark + } +} + +// Usage in Composable +@Composable +fun NavigationItem(iconName: String, label: String) { + val iconRes = remember(iconName) { IconResources.getIconRes(iconName) } + + Icon(painter = painterResource(iconRes), contentDescription = label) + Text(text = label) +} + +// Better: Use rememberImagePainter for network images +@Composable +fun UserAvatar(imageUrl: String) { + // Coil's internal cache acts as Flyweight + AsyncImage( + model = imageUrl, + contentDescription = "User avatar", + modifier = Modifier.size(48.dp) + ) +} +``` + +### Proxy +- **When**: You need to control access to an expensive or remote object. +- **Android use**: Lazy initialization for analytics/crash reporters, remote data sources. +- Proxy logic stays in the data layer; never expose it to UI or domain. + +```kotlin +// core/data - Proxy for lazy analytics initialization +class LazyAnalyticsProxy @Inject constructor( + private val context: Context +) : Analytics { + private val analytics: FirebaseAnalytics by lazy { + FirebaseAnalytics.getInstance(context) + } + + override fun logEvent(name: String, params: Map) { + analytics.logEvent(name, bundleOf(*params.toList().toTypedArray())) + } + + override fun setUserId(userId: String) { + analytics.setUserId(userId) + } +} + +// core/data - Proxy for caching remote data +class CachedAuthDataSource @Inject constructor( + private val remoteDataSource: AuthRemoteDataSource, + private val cache: MutableMap = mutableMapOf() +) : AuthDataSource { + + override suspend fun getUser(userId: String): User = + cache.getOrPut(userId) { + remoteDataSource.getUser(userId) // Only fetch if not cached + } + + fun invalidateCache() { + cache.clear() + } +} +``` + +## Behavioral Patterns + +### Observer +- **When**: Many dependents must react to state changes. +- **Android use**: `Flow`, `StateFlow` in ViewModels and repositories. +- Required: `Flow` / `StateFlow`. Forbidden: `LiveData`. Patterns: [coroutines-patterns.md](/references/coroutines-patterns.md). + +```kotlin +// core/domain +@Stable +interface AuthRepository { + fun observeAuthState(): Flow // Observable +} + +// core/data +class AuthRepositoryImpl @Inject constructor( + private val localDataSource: AuthLocalDataSource +) : AuthRepository { + private val _authState = MutableStateFlow(AuthState.Unauthenticated) + + override fun observeAuthState(): Flow = _authState.asStateFlow() + + suspend fun login(email: String, password: String) { + _authState.value = AuthState.Loading + // ... login logic + _authState.value = AuthState.Authenticated(user) + } +} + +// feature/auth - Observer (ViewModel) +@HiltViewModel +class AuthViewModel @Inject constructor( + authRepository: AuthRepository +) : ViewModel() { + val authState: StateFlow = authRepository + .observeAuthState() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = AuthState.Loading + ) +} + +// feature/auth - Observer (UI) +@Composable +fun AuthScreen(viewModel: AuthViewModel = hiltViewModel()) { + val authState by viewModel.authState.collectAsStateWithLifecycle() + + when (authState) { + is AuthState.Loading -> LoadingIndicator() + is AuthState.Authenticated -> WelcomeScreen() + is AuthState.Unauthenticated -> LoginForm() + } +} +``` + +### Strategy +- **When**: Multiple interchangeable algorithms are needed. +- **Android use**: Auth providers, caching strategies, feature flag resolution. +- Inject the strategy via DI. Forbidden: branching on `BuildConfig.FLAVOR` inside business logic. + +```kotlin +// core/domain - Strategy interface +interface AuthStrategy { + suspend fun authenticate(credentials: AuthCredentials): Result +} + +// core/data - Email/Password strategy +class EmailPasswordAuthStrategy @Inject constructor( + private val api: AuthApi +) : AuthStrategy { + override suspend fun authenticate(credentials: AuthCredentials): Result = + runCatching { + api.loginWithEmail(credentials.email, credentials.password) + } +} + +// core/data - Google OAuth strategy +class GoogleAuthStrategy @Inject constructor( + private val googleSignIn: GoogleSignInClient +) : AuthStrategy { + override suspend fun authenticate(credentials: AuthCredentials): Result = + runCatching { + val account = googleSignIn.signIn() + api.loginWithGoogle(account.idToken) + } +} + +// core/data - Biometric strategy +class BiometricAuthStrategy @Inject constructor( + private val biometricManager: BiometricManager +) : AuthStrategy { + override suspend fun authenticate(credentials: AuthCredentials): Result = + suspendCancellableCoroutine { continuation -> + biometricManager.authenticate( + onSuccess = { continuation.resume(Result.success(it)) }, + onFailure = { continuation.resume(Result.failure(it)) } + ) + } +} + +// core/data - Repository uses injected strategy +class AuthRepositoryImpl @Inject constructor( + @EmailPassword private val emailStrategy: AuthStrategy, + @Google private val googleStrategy: AuthStrategy, + @Biometric private val biometricStrategy: AuthStrategy +) : AuthRepository { + + override suspend fun login(type: AuthType, credentials: AuthCredentials): Result { + val strategy = when (type) { + AuthType.EMAIL -> emailStrategy + AuthType.GOOGLE -> googleStrategy + AuthType.BIOMETRIC -> biometricStrategy + } + return strategy.authenticate(credentials) + } +} +``` + +### Chain of Responsibility +- **When**: A request should pass through a sequence of handlers. +- **Android use**: OkHttp interceptors, validation pipelines, auth token refresh chain. +- One concern per handler; each must be unit-testable in isolation. + +```kotlin +// core/network - Chain of interceptors +class AuthInterceptor @Inject constructor( + private val tokenProvider: TokenProvider +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val token = tokenProvider.getToken() + val request = chain.request().newBuilder() + .addHeader("Authorization", "Bearer $token") + .build() + return chain.proceed(request) // Pass to next handler + } +} + +class RetryInterceptor @Inject constructor() : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + var response = chain.proceed(request) + + var retryCount = 0 + while (!response.isSuccessful && retryCount < MAX_RETRIES) { + response.close() + retryCount++ + Thread.sleep(RETRY_DELAY_MS) + response = chain.proceed(request) + } + + return response + } + + companion object { + private const val MAX_RETRIES = 3 + private val RETRY_DELAY = 1.seconds + } +} + +class LoggingInterceptor @Inject constructor( + private val logger: Logger +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + logger.d("Request: ${request.method} ${request.url}") + + val response = chain.proceed(request) + + logger.d("Response: ${response.code} ${request.url}") + return response + } +} + +// DI setup - Chain is configured here +@Provides +@Singleton +fun provideOkHttpClient( + authInterceptor: AuthInterceptor, + retryInterceptor: RetryInterceptor, + loggingInterceptor: LoggingInterceptor +): OkHttpClient = OkHttpClient.Builder() + .addInterceptor(authInterceptor) // First handler + .addInterceptor(retryInterceptor) // Second handler + .addInterceptor(loggingInterceptor) // Third handler + .build() +``` + +### Command +- **When**: You want to encapsulate actions as objects. +- **Android use**: UI actions/intents from screens → ViewModel. +- Required: sealed `Action` types in the presentation layer; one `onAction` entry point. Patterns: [compose-patterns.md](/references/compose-patterns.md). + +```kotlin +// feature/auth - Commands (Actions) +sealed interface AuthAction { + data class EmailChanged(val email: String) : AuthAction + data class PasswordChanged(val password: String) : AuthAction + data object LoginClicked : AuthAction + data object RegisterClicked : AuthAction + data object ForgotPasswordClicked : AuthAction + data object GoogleLoginClicked : AuthAction +} + +// feature/auth - Command processor (ViewModel) +@HiltViewModel +class AuthViewModel @Inject constructor( + private val authRepository: AuthRepository +) : ViewModel() { + + fun onAction(action: AuthAction) { + when (action) { + is AuthAction.EmailChanged -> handleEmailChanged(action.email) + is AuthAction.PasswordChanged -> handlePasswordChanged(action.password) + AuthAction.LoginClicked -> handleLogin() + AuthAction.RegisterClicked -> handleRegister() + AuthAction.ForgotPasswordClicked -> handleForgotPassword() + AuthAction.GoogleLoginClicked -> handleGoogleLogin() + } + } + + private fun handleLogin() { + viewModelScope.launch { + authRepository.login(email, password) + } + } + + private fun handleGoogleLogin() { + viewModelScope.launch { + authRepository.loginWithGoogle() + } + } +} + +// feature/auth - UI sends commands +@Composable +fun LoginScreen(onAction: (AuthAction) -> Unit) { + Button(onClick = { onAction(AuthAction.LoginClicked) }) { + Text("Login") + } + + Button(onClick = { onAction(AuthAction.GoogleLoginClicked) }) { + Text("Login with Google") + } +} +``` + +### Iterator +- **When**: You need sequential access without exposing structure. +- **Android use**: Paging data flows, cursor traversal in data layer. +- Iteration belongs in data / paging layers; UI consumes `Flow>`. + +```kotlin +// core/data - Iterator for paginated data +class PaginatedUserIterator( + private val api: UserApi, + private val pageSize: Int = 20 +) { + private var currentPage = 0 + private var hasMore = true + + suspend fun hasNext(): Boolean = hasMore + + suspend fun next(): List { + if (!hasMore) return emptyList() + + val response = api.getUsers(page = currentPage, size = pageSize) + currentPage++ + hasMore = response.users.size == pageSize + + return response.users + } +} + +// Better: Use Flow for continuous iteration +class UserRepository @Inject constructor( + private val api: UserApi +) { + fun getUsers(): Flow> = Pager( + config = PagingConfig(pageSize = 20), + pagingSourceFactory = { UserPagingSource(api) } + ).flow +} + +// core/database - Cursor iterator +class DatabaseCursor(private val cursor: Cursor) : Iterator { + override fun hasNext(): Boolean = !cursor.isAfterLast + + override fun next(): User { + val user = User( + id = cursor.getString(cursor.getColumnIndexOrThrow("id")), + name = cursor.getString(cursor.getColumnIndexOrThrow("name")) + ) + cursor.moveToNext() + return user + } +} +``` + +### Mediator +- **When**: Multiple components need coordinated interaction. +- **Android use**: App-level navigation coordinator (`AppNavigation`). +- Features stay independent; only the `app` module knows the full graph. See [modularization.md](/references/modularization.md). + +```kotlin +// app - Mediator coordinates feature navigation using Navigation3 +class AppNavigationMediator @Inject constructor( + private val navigator: Navigator +) : AuthNavigator, ProfileNavigator, SettingsNavigator { + + // AuthNavigator implementation + override fun navigateToHome() { + navigator.navigate(TopLevelRoute.Home) + } + + override fun navigateToProfile() { + navigator.navigate(TopLevelRoute.Profile) + } + + // ProfileNavigator implementation + override fun navigateToSettings() { + navigator.navigate(TopLevelRoute.Settings) + } + + override fun navigateToAuth() { + navigator.navigate(TopLevelRoute.Auth) + } + + // SettingsNavigator implementation + override fun navigateBack() { + navigator.goBack() + } + + override fun logout() { + navigator.navigate(TopLevelRoute.Auth) + } +} + +// Features don't know about each other, only their own Navigator interface +// The mediator coordinates cross-feature navigation +``` + +### Memento +- **When**: You must restore state without breaking encapsulation. +- **Android use**: `SavedStateHandle`, restoring form drafts or auth flows. +- Snapshots must be `@Serializable` and minimal; never store derived data. + +```kotlin +// feature/auth - Memento (state snapshot) +@Serializable +data class AuthFormMemento( + val email: String, + val password: String, + val rememberMe: Boolean, + val timestamp: Long = Clock.System.now().toEpochMilliseconds() +) + +// feature/auth - Originator (creates and restores mementos) +@HiltViewModel +class AuthViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle +) : ViewModel() { + + private companion object { + const val KEY_FORM_STATE = "auth_form_state" + } + + init { + // Restore from memento + restoreState() + } + + private val _email = MutableStateFlow("") + val email: StateFlow = _email.asStateFlow() + + private val _password = MutableStateFlow("") + val password: StateFlow = _password.asStateFlow() + + private val _rememberMe = MutableStateFlow(false) + val rememberMe: StateFlow = _rememberMe.asStateFlow() + + fun onEmailChanged(value: String) { + _email.value = value + saveState() + } + + fun onPasswordChanged(value: String) { + _password.value = value + saveState() + } + + fun onRememberMeChanged(value: Boolean) { + _rememberMe.value = value + saveState() + } + + private fun saveState() { + val memento = AuthFormMemento( + email = _email.value, + password = _password.value, + rememberMe = _rememberMe.value + ) + savedStateHandle[KEY_FORM_STATE] = memento + } + + private fun restoreState() { + savedStateHandle.get(KEY_FORM_STATE)?.let { memento -> + _email.value = memento.email + _password.value = memento.password + _rememberMe.value = memento.rememberMe + } + } +} +``` + +### State +- **When**: Behavior changes with state. +- **Android use**: `UiState` sealed types and state-driven UI. +- Transitions live in the ViewModel; UI is a pure render of `UiState`. See [compose-patterns.md](/references/compose-patterns.md). + +```kotlin +// feature/auth - State hierarchy +@Immutable +sealed interface AuthUiState { + data object Loading : AuthUiState + + data class LoginForm( + val email: String = "", + val password: String = "", + val error: String? = null, + val isLoading: Boolean = false + ) : AuthUiState + + data class TwoFactorRequired( + val maskedPhone: String + ) : AuthUiState + + data class Success( + val user: User + ) : AuthUiState + + data class Error( + val message: String, + val canRetry: Boolean = true + ) : AuthUiState +} + +// feature/auth - State machine (ViewModel) +@HiltViewModel +class AuthViewModel @Inject constructor( + private val authRepository: AuthRepository +) : ViewModel() { + private val _uiState = MutableStateFlow(AuthUiState.LoginForm()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onLoginClicked() { + val currentState = _uiState.value as? AuthUiState.LoginForm ?: return + + // Transition: LoginForm → Loading + _uiState.value = currentState.copy(isLoading = true, error = null) + + viewModelScope.launch { + authRepository.login(currentState.email, currentState.password).fold( + onSuccess = { result -> + // Transition based on result + _uiState.value = when { + result.requires2FA -> AuthUiState.TwoFactorRequired(result.maskedPhone) + else -> AuthUiState.Success(result.user) + } + }, + onFailure = { error -> + // Transition: Loading → LoginForm with error + _uiState.value = currentState.copy( + isLoading = false, + error = error.message + ) + } + ) + } + } +} + +// feature/auth - State-driven UI +@Composable +fun AuthScreen(uiState: AuthUiState) { + when (uiState) { + is AuthUiState.Loading -> LoadingScreen() + is AuthUiState.LoginForm -> LoginFormScreen(uiState) + is AuthUiState.TwoFactorRequired -> TwoFactorScreen(uiState) + is AuthUiState.Success -> SuccessScreen(uiState) + is AuthUiState.Error -> ErrorScreen(uiState) + } +} +``` + +### Template Method +- **When**: You need a fixed algorithm with varying steps. +- **Android use**: Base worker patterns or shared use case flows (use sparingly). +- Forbidden: `abstract` "Base*UseCase" hierarchies. Compose with strategies / delegation instead. See [kotlin-delegation.md](/references/kotlin-delegation.md). + +```kotlin +// WRONG: Inheritance-based template method +abstract class BaseAuthUseCase { + suspend fun execute(credentials: Credentials): Result { + validate(credentials) + val token = authenticate(credentials) + saveToken(token) + return Result.success(token) + } + + protected abstract fun validate(credentials: Credentials) + protected abstract suspend fun authenticate(credentials: Credentials): AuthToken + protected abstract suspend fun saveToken(token: AuthToken) +} + +// CORRECT: Composition-based (Strategy pattern) +interface CredentialsValidator { + fun validate(credentials: Credentials) +} + +interface Authenticator { + suspend fun authenticate(credentials: Credentials): AuthToken +} + +interface TokenStorage { + suspend fun saveToken(token: AuthToken) +} + +class LoginUseCase @Inject constructor( + private val validator: CredentialsValidator, + private val authenticator: Authenticator, + private val storage: TokenStorage +) { + suspend operator fun invoke(credentials: Credentials): Result = + runCatching { + validator.validate(credentials) + val token = authenticator.authenticate(credentials) + storage.saveToken(token) + token + } +} +``` + +### Visitor +- **When**: You need to run operations over a structure without changing it. +- **Android use**: Analytics/event inspection over `UiState` or navigation events. +- Use only when a `when` over a sealed hierarchy would explode call sites; otherwise prefer extension functions. + +```kotlin +// core/analytics - Visitor interface +interface AnalyticsVisitor { + fun visit(state: AuthUiState.LoginForm) + fun visit(state: AuthUiState.TwoFactorRequired) + fun visit(state: AuthUiState.Success) + fun visit(state: AuthUiState.Error) +} + +// core/analytics - Concrete visitor +class FirebaseAnalyticsVisitor @Inject constructor( + private val analytics: FirebaseAnalytics +) : AnalyticsVisitor { + + override fun visit(state: AuthUiState.LoginForm) { + analytics.logEvent("auth_login_form_shown", bundleOf( + "has_error" to (state.error != null) + )) + } + + override fun visit(state: AuthUiState.TwoFactorRequired) { + analytics.logEvent("auth_2fa_required", bundleOf( + "phone_masked" to state.maskedPhone + )) + } + + override fun visit(state: AuthUiState.Success) { + analytics.logEvent("auth_success", bundleOf( + "user_id" to state.user.id + )) + } + + override fun visit(state: AuthUiState.Error) { + analytics.logEvent("auth_error", bundleOf( + "error_message" to state.message, + "can_retry" to state.canRetry + )) + } +} + +// feature/auth - States accept visitors +@Immutable +sealed interface AuthUiState { + fun accept(visitor: AnalyticsVisitor) + + data class LoginForm(...) : AuthUiState { + override fun accept(visitor: AnalyticsVisitor) = visitor.visit(this) + } + + data class TwoFactorRequired(...) : AuthUiState { + override fun accept(visitor: AnalyticsVisitor) = visitor.visit(this) + } + + data class Success(...) : AuthUiState { + override fun accept(visitor: AnalyticsVisitor) = visitor.visit(this) + } + + data class Error(...) : AuthUiState { + override fun accept(visitor: AnalyticsVisitor) = visitor.visit(this) + } +} + +// Usage +val visitor = FirebaseAnalyticsVisitor(analytics) +uiState.accept(visitor) +``` + +## Kotlin-Specific Patterns + +### Result Type +- **When**: Operations can fail and you need type-safe error handling. +- **Android use**: Repository methods, use cases, network calls. +- Required: `Result` for expected failures. Forbidden: throwing across layer boundaries for known errors. + +```kotlin +// core/domain - Use Result for fallible operations +@Stable +interface AuthRepository { + suspend fun login(email: String, password: String): Result + suspend fun register(user: User): Result +} + +// core/data - Implementation +class AuthRepositoryImpl @Inject constructor( + private val remoteDataSource: AuthRemoteDataSource +) : AuthRepository { + + override suspend fun login(email: String, password: String): Result = + try { + val token = remoteDataSource.login(email, password) + Result.success(token) + } catch (e: IOException) { + Result.failure(AuthError.NetworkError("No internet connection", e)) + } catch (e: HttpException) { + when (e.code()) { + 401 -> Result.failure(AuthError.InvalidCredentials("Invalid credentials")) + else -> Result.failure(AuthError.ServerError("Server error", e)) + } + } +} + +// feature/auth - Handle Result +@HiltViewModel +class AuthViewModel @Inject constructor( + private val authRepository: AuthRepository +) : ViewModel() { + + fun onLoginClicked(email: String, password: String) { + viewModelScope.launch { + authRepository.login(email, password).fold( + onSuccess = { token -> + _uiState.update { AuthUiState.Success(token.user) } + }, + onFailure = { error -> + val message = when (error) { + is AuthError.NetworkError -> "No internet connection" + is AuthError.InvalidCredentials -> "Invalid email or password" + is AuthError.ServerError -> "Server error. Please try again" + else -> "Unknown error" + } + _uiState.update { AuthUiState.Error(message) } + } + ) + } + } +} +``` + +### Sealed Classes for Exhaustive State +- **When**: You need a closed set of related types with exhaustive `when` checks. +- **Android use**: `UiState`, domain errors, navigation destinations, actions. +- Required: `sealed interface` / `sealed class` for every closed state or error hierarchy. Forbidden: open enums for behaviour-bearing states. + +```kotlin +// core/domain - Sealed error hierarchy +sealed class AuthError(message: String, cause: Throwable? = null) : Exception(message, cause) { + class NetworkError(message: String, cause: Throwable? = null) : AuthError(message, cause) + class InvalidCredentials(message: String) : AuthError(message) + class UserAlreadyExists(message: String) : AuthError(message) + class ServerError(message: String, cause: Throwable? = null) : AuthError(message, cause) + class UnknownError(message: String, cause: Throwable? = null) : AuthError(message, cause) +} + +// feature/auth - Exhaustive when +fun handleAuthError(error: AuthError): String = when (error) { + is AuthError.NetworkError -> "No internet connection" + is AuthError.InvalidCredentials -> "Invalid email or password" + is AuthError.UserAlreadyExists -> "Email already registered" + is AuthError.ServerError -> "Server error. Please try again" + is AuthError.UnknownError -> "An unexpected error occurred" +} // Compiler ensures all cases are handled + +// core/domain - Sealed navigation destinations +sealed interface AuthDestination { + data object Login : AuthDestination + data object Register : AuthDestination + data class ResetPassword(val email: String) : AuthDestination + data class TwoFactor(val phone: String) : AuthDestination +} +``` + +### Data Classes with Copy +- **When**: You need immutable value objects with easy modification. +- **Android use**: Domain models, UI state, DTOs. +- Annotate `@Immutable` on every UI-facing data class. + +```kotlin +@Immutable +data class User( + val id: String, + val email: String, + val name: String, + val profileUrl: String? = null, + val isVerified: Boolean = false, + val createdAt: Long = 0L +) + +// Easy immutable updates with copy() +val user = User(id = "1", email = "test@example.com", name = "Test") +val verified = user.copy(isVerified = true) // New instance, original unchanged + +// In ViewModel +@HiltViewModel +class ProfileViewModel @Inject constructor() : ViewModel() { + private val _user = MutableStateFlow(User("", "", "")) + val user: StateFlow = _user.asStateFlow() + + fun updateName(newName: String) { + _user.update { it.copy(name = newName) } + } + + fun markVerified() { + _user.update { it.copy(isVerified = true) } + } +} +``` + +### Extension Functions for Domain Logic +- **When**: You need to add functionality to existing types without inheritance. +- **Android use**: Domain transformations, UI formatting, validation. +- Place extensions in the type's owning module or in `core/common`. Never colocate UI-formatting extensions in `core/domain`. + +```kotlin +// core/domain - Domain extensions +fun User.isActive(): Boolean = isVerified && createdAt > 0L + +fun User.displayName(): String = name.ifEmpty { email.substringBefore("@") } + +fun List.filterActive(): List = filter { it.isActive() } + +// core/ui - UI extensions +fun User.toUiModel(): UserUiModel = UserUiModel( + name = displayName(), + email = email, + avatarUrl = profileUrl, + badge = if (isVerified) "Verified" else null +) + +fun Instant.formatRelativeTime(): String { + val now = Clock.System.now() + val duration = now - this + + return when { + duration < 1.minutes -> "Just now" + duration < 1.hours -> "${duration.inWholeMinutes}m ago" + duration < 24.hours -> "${duration.inWholeHours}h ago" + else -> "${duration.inWholeDays}d ago" + } +} + +// Usage +@Composable +fun UserCard(user: User) { + if (user.isActive()) { + Text(text = user.displayName()) + Text(text = user.createdAt.formatRelativeTime()) + } +} +``` + +## Anti-Patterns to Avoid + +### Static Context References +- **Problem**: Holding `Context` in static objects causes memory leaks. +- **Solution**: Inject `Context` via DI or use `Application` context. + +```kotlin +// WRONG: Static context +object BadLogger { + private lateinit var context: Context + + fun init(context: Context) { + this.context = context // Memory leak! + } +} + +// CORRECT: Injected context +@Singleton +class Logger @Inject constructor( + @ApplicationContext private val context: Context +) +``` + +### LiveData in New Code +- **Problem**: LiveData is lifecycle-aware but lacks Flow's power. +- **Solution**: Use `StateFlow` and `collectAsStateWithLifecycle()` in Compose. + +```kotlin +// WRONG: LiveData in new Compose code +class BadViewModel : ViewModel() { + private val _state = MutableLiveData() + val state: LiveData = _state +} + +// CORRECT: StateFlow +class GoodViewModel : ViewModel() { + private val _state = MutableStateFlow(UiState.Loading) + val state: StateFlow = _state.asStateFlow() +} +``` + +### God Objects +- **Problem**: One class does too much, violating Single Responsibility. +- **Solution**: Split into focused components. + +```kotlin +// WRONG: God object +class AuthManager { + fun validateEmail(email: String): Boolean { } + fun validatePassword(password: String): Boolean { } + fun login(email: String, password: String) { } + fun register(user: User) { } + fun resetPassword(email: String) { } + fun saveToken(token: String) { } + fun getToken(): String? { } + fun logout() { } + fun refreshToken() { } + fun checkAuthStatus() { } +} + +// CORRECT: Separated concerns +interface AuthRepository { + suspend fun login(email: String, password: String): Result + suspend fun register(user: User): Result +} + +interface TokenStorage { + suspend fun saveToken(token: String) + suspend fun getToken(): String? +} + +class EmailValidator { + fun validate(email: String): Boolean +} + +class PasswordValidator { + fun validate(password: String): Boolean +} +``` + +### GlobalScope Usage +- **Problem**: Survives ViewModel/Activity lifecycle, causes leaks. +- **Solution**: Use `viewModelScope`, `lifecycleScope`, or custom scopes. + +```kotlin +// WRONG: GlobalScope +class BadViewModel : ViewModel() { + fun loadData() { + GlobalScope.launch { // Survives ViewModel! + repository.getData() + } + } +} + +// CORRECT: viewModelScope +class GoodViewModel : ViewModel() { + fun loadData() { + viewModelScope.launch { // Canceled when ViewModel cleared + repository.getData() + } + } +} +``` + +### Mutable Collections in Data Classes +- **Problem**: Breaks immutability contract; Compose can't detect changes. +- **Solution**: Use immutable collections or `PersistentList`. + +```kotlin +// WRONG: Mutable collection +@Immutable // This is a lie! +data class UserList( + val users: MutableList +) + +// CORRECT: Immutable collection +@Immutable +data class UserList( + val users: List // Immutable interface +) + +// CORRECT: Better — Persistent collection +@Immutable +data class UserList( + val users: PersistentList // Efficient immutable updates +) +``` + +### Premature Abstraction +- **Problem**: Adding patterns before they're needed. +- **Solution**: Start simple, refactor when complexity emerges. + +```kotlin +// WRONG: Over-engineered for simple case +interface UserRepository { + suspend fun getUser(): Result +} + +class UserRepositoryImpl @Inject constructor( + private val dataSourceFactory: UserDataSourceFactory +) : UserRepository { + override suspend fun getUser(): Result { + val dataSource = dataSourceFactory.create() + return dataSource.fetch() + } +} + +// CORRECT: Simple, direct +class UserRepository @Inject constructor( + private val api: UserApi +) { + suspend fun getUser(): Result = runCatching { + api.getUser() + } +} +``` + +### Nested Callbacks (Callback Hell) +- **Problem**: Hard to read and maintain. +- **Solution**: Use coroutines and structured concurrency. + +```kotlin +// WRONG: Callback hell +fun login(email: String, password: String, callback: (Result) -> Unit) { + validateEmail(email) { isValid -> + if (isValid) { + authenticateUser(email, password) { authResult -> + if (authResult.success) { + saveToken(authResult.token) { + loadUserProfile(authResult.userId) { profile -> + callback(Result.Success(profile)) + } + } + } else { + callback(Result.Error("Auth failed")) + } + } + } else { + callback(Result.Error("Invalid email")) + } + } +} + +// CORRECT: Coroutines with sequential clarity +suspend fun login(email: String, password: String): Result = + try { + validateEmail(email) + val authResult = authenticateUser(email, password) + saveToken(authResult.token) + val profile = loadUserProfile(authResult.userId) + Result.success(profile) + } catch (e: Exception) { + Result.failure(e) + } +``` + +### Feature-to-Feature Dependencies +- **Problem**: Creates coupling; breaks modularity. +- **Solution**: Use app module as mediator with `Navigator` interfaces. + +```kotlin +// WRONG: Feature depends on another feature +// feature/profile +class ProfileViewModel @Inject constructor( + private val authViewModel: AuthViewModel // Feature-to-feature dependency! +) : ViewModel() + +// CORRECT: Features depend on domain, app mediates +// feature/profile +interface ProfileNavigator { + fun navigateToAuth() +} + +class ProfileViewModel @Inject constructor( + private val navigator: ProfileNavigator // Interface in feature, impl in app +) : ViewModel() + +// app - Mediator +class AppNavigator(private val navigator: Navigator) : ProfileNavigator, AuthNavigator { + override fun navigateToAuth() { + navigator.navigate(TopLevelRoute.Auth) + } +} +``` + +## Room Database Patterns + +Guidance targets **Room 3** (`androidx.room3`): annotations such as `@Dao`, `@Entity`, `@Query` live in the `androidx.room3` package, and the database **must** be built with `.setDriver(...)` (for example [`BundledSQLiteDriver`](https://developer.android.com/reference/kotlin/androidx/sqlite/driver/bundled/BundledSQLiteDriver)). Invalidation is **Flow**-based (`InvalidationTracker.createFlow`); do not use removed `InvalidationTracker.Observer` APIs. + +### The `@Upsert` Caveat +Use `@Insert(onConflict = OnConflictStrategy.REPLACE)` instead of `@Upsert` if you need to return the inserted row ID. `@Upsert` returns `-1` on updates, which can break logic depending on the ID. + +```kotlin +@Dao +interface UserDao { + // Bad: Returns -1 if the user already exists and is updated + @Upsert + suspend fun upsertUser(user: UserEntity): Long + + // Good: Always returns the row ID + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertOrUpdateUser(user: UserEntity): Long +} +``` + +`OnConflictStrategy.REPLACE` is implemented as **delete then insert** for the conflicting row. That can trigger **foreign-key `ON DELETE CASCADE`** on dependent rows. Use `@Upsert` when you want update-in-place semantics without that delete path, unless you intentionally rely on `REPLACE`. + +### Critical Performance Rules +1. **Never use `Flow>` for large tables**: It loads the entire table into memory on every change. Use Paging 3 instead. +2. **Always use specific column queries**: Avoid `SELECT *` if you only need a few columns. +3. **Use `@Transaction` for multiple operations**: Ensures atomicity and improves performance by batching disk writes. +4. **Index what you filter, sort, and join**: Add `@Entity(indices = [...])` (or migration `CREATE INDEX`) for columns in `WHERE`, `ORDER BY`, `JOIN`, and foreign keys. Unindexed predicates often force full table scans. +5. **`@Relation` and multi-query reads**: DAO methods that return `@Relation` graphs run more than one query. Annotate those methods with `@Transaction` so Room uses a single database snapshot across the queries. +6. **Avoid N+1 access patterns**: Do not load a parent list then query per row in a loop. Use one query with `JOIN`, `IN (:ids)`, or a single `@Relation` / projection query. +7. **Never `allowMainThreadQueries()` in production**: It blocks the UI thread and risks ANRs. Use `suspend` or `Flow` from the DAO. +8. **One `RoomDatabase` instance per database name**: Provide it as a DI singleton (`@Singleton`). Multiple instances waste memory and break invalidation expectations. +9. **Large binary payloads**: Store a **file path** or content URI in the database and keep blobs on disk; huge `BLOB` columns slow reads and backups. + +### Full-Text Search (FTS) Pattern +Use Room's FTS4 support for fast, efficient text searching instead of `LIKE '%query%'`. + +```kotlin +// 1. Define the FTS entity +@Entity(tableName = "notes_fts") +@Fts4(contentEntity = NoteEntity::class) +data class NoteFtsEntity( + @ColumnInfo(name = "rowid") val rowId: Int, + val title: String, + val content: String +) + +// 2. Define the main entity +@Entity(tableName = "notes") +data class NoteEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "rowid") + val id: Int = 0, + val title: String, + val content: String +) + +// 3. Query using MATCH +@Dao +interface NoteDao { + @Query(""" + SELECT notes.* FROM notes + JOIN notes_fts ON notes.rowid = notes_fts.rowid + WHERE notes_fts MATCH :query + """) + fun searchNotes(query: String): Flow> +} +``` + diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/references/gradle-setup.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/gradle-setup.md new file mode 100644 index 000000000..bbc450a2b --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/gradle-setup.md @@ -0,0 +1,1052 @@ +# Gradle & Build Configuration + +Required: Gradle 9.x wrapper, JVM 17+, KSP (never kapt), version catalog, convention plugins in `build-logic/convention`. Module structure follows [modularization.md](/references/modularization.md). Gradle wrapper and catalog `agp` are independent pins; a high Gradle version does not force a matching AGP patch. + +## AGP 9 Key Changes + +- **Built-in Kotlin**: AGP 9 has built-in Kotlin support. The `org.jetbrains.kotlin.android` plugin is no longer needed for Android modules. Remove it from all `build.gradle.kts` files and convention plugins. +- **Compose Compiler**: The `org.jetbrains.kotlin.plugin.compose` plugin is still required for Compose modules. +- **compileSdk syntax**: Use `compileSdk { version = release(37) }` instead of `compileSdk = 37`. AGP 9.0+ supports `compileSdk` 37 (Android 17) on the stable channel; no `compileSdkPreview` flag is needed. +- **Gradle Managed Devices**: Use `localDevices { create("name") { ... } }` instead of `devices { maybeCreate("name", ManagedVirtualDevice::class.java).apply { ... } }`. Device groups use `create("ci")` instead of `maybeCreate("ci")`. Reference devices via `localDevices[name]` instead of `devices[name]`. +- **Removed gradle.properties**: `org.gradle.configureondemand`, `android.enableBuildCache`, `android.enableJetifier`, `android.defaults.buildfeatures.aidl`, `android.defaults.buildfeatures.renderscript`, `android.defaults.buildfeatures.resvalues`, `android.defaults.buildfeatures.shaders`, and `org.gradle.configuration-cache.problems=warn` are removed. +- **CommonExtension**: Type parameters removed; use `CommonExtension` instead of `CommonExtension<*, *, *, *, *, *>`. +- **KotlinAndroidProjectExtension**: Not registered with built-in Kotlin; configure compiler options via `tasks.withType().configureEach { compilerOptions { ... } }` instead. +- **Hilt**: Minimum version **2.59.2** required for AGP 9 (older versions access removed `BaseExtension`). +- **KSP**: Use the **KSP2** line on Maven Central ([KSP releases](https://github.com/google/ksp/releases)). Catalog `ksp` may be a `kotlinVersion-kspToolVersion` string (e.g. `2.2.21-2.0.5`) or a standalone KSP release (e.g. `2.3.7`); the KSP patch does not have to match the Kotlin patch. Pick the highest KSP release that lists support for the catalog `kotlin` version, then verify `./gradlew help`. KSP1 (`*-1.0.x`) is incompatible with AGP 9. +- **kapt fallback (`legacy-kapt`)**: Use KSP everywhere it exists. If a processor has no KSP equivalent under AGP 9, use the **`org.jetbrains.kotlin.kapt`** plugin (a.k.a. `legacy-kapt`) for that single module only; the new built-in Kotlin pipeline does not run kapt automatically. +- **Type-safe project accessors**: Enabled by default in Gradle 9; `enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")` is no longer needed in `settings.gradle.kts`. +- **JVM 17 minimum**: Gradle 9 requires JVM 17+ to run. +- **Legacy API removal**: `BaseExtension`, `applicationVariants.all`, `Convention` type, and `com.android.build.gradle.api.*` legacy APIs are removed. Use `androidComponents` API instead. + +### AGP 9 Migration: Post-Upgrade Cleanup + +After completing the AGP 9 upgrade, remove these now-obsolete flags from `gradle.properties` (they were only needed during incremental migration and are no-ops or warnings under AGP 9): + +- `android.builtInKotlin` +- `android.newDsl` +- `android.uniquePackageNames` +- `android.enableAppCompileTimeRClass` + +Do **not** add `android.disallowKotlinSourceSets=false`. It re-enables a removed escape hatch and masks real migration work. + +### Built-in Kotlin (AGP 9) + +| Situation | Action | +|---------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Default Android modules on AGP 9 | Leave AGP built-in Kotlin enabled. Do not apply `org.jetbrains.kotlin.android` on Android modules. Keep `org.jetbrains.kotlin.plugin.compose` on Compose modules via the compose convention plugins. | +| `gradle.properties` during API 37 migration | Do not set `android.builtInKotlin=false` to chase a standalone Kotlin Gradle plugin unless plugin order and extension wiring are fully planned; disabling built-in Kotlin mid-migration causes extension type mismatches with convention plugins. | +| After AGP 9 stabilizes | Delete stale `android.builtInKotlin` lines from `gradle.properties` as part of [AGP 9 Migration: Post-Upgrade Cleanup](#agp-9-migration-post-upgrade-cleanup). | + +### AGP version pin (resolve before merge) + +Required: +- After changing catalog `agp`, run `./gradlew help`. Failure to resolve `com.android.tools.build:gradle:` (HTTP 404 from `google()`) means that exact version is not published yet; pick the highest published AGP that still supports `compileSdk` 37. Cross-check [Android Gradle Plugin release notes](https://developer.android.com/build/releases/gradle-plugin). +- Treat Gradle compatibility tables as JVM / Gradle runtime guidance only; they do not guarantee every future AGP coordinate exists on Maven. + +### Example tested stack (re-verify after every bump) + +| Gradle wrapper | catalog `agp` | catalog `kotlin` | catalog `ksp` | Verify | +|----------------|---------------|------------------|---------------|---------------------------------------------------------------------------------------------------------| +| 9.5.x | 9.2.x | 2.3.21 | 2.3.7 | `./gradlew help` on a clean checkout after editing `libs.versions.toml`; swap pins if resolution fails. | + +### AGP 9 Verification + +Run after every AGP 9 build-config change. Do **not** run `clean` first - it does not validate the DSL. + +```bash +./gradlew help # Gradle IDE-equivalent sync +./gradlew build --dry-run # Configures every task without executing +``` + +On failure, the failing task name identifies the module / DSL block to fix. For `MissingValueException` / "provider has no value" during `compile*JavaWithJavac`, capture `./gradlew help --stacktrace` before changing Kotlin; isolate JaCoCo combined-report wiring per [android-code-coverage.md](/references/android-code-coverage.md). + +### AGP 9 Toolchain Compatibility Notes + +- **Paparazzi**: Versions **`<= 2.0.0-alpha04`** are incompatible with AGP 9. Upgrade to a release that explicitly supports AGP 9 before flipping the AGP version, or temporarily disable Paparazzi modules. +- **KMP**: This AGP 9 path is Android-only. Kotlin Multiplatform projects require a separate migration. + +## Table of Contents +1. [Project Structure](#project-structure) +2. [Version Catalog](#version-catalog) +3. [Convention Plugins](#convention-plugins) (includes [root-level reporting task registration](#registering-a-root-level-reporting-task-play-vitals-example)) +4. [Code Quality (Detekt)](#code-quality-detekt) +5. [Module Build Files](#module-build-files) +6. [Build Variants & Optimization](#build-variants--optimization) +7. [Build Performance](#build-performance) + +## Project Structure + +Module layout and naming: [modularization.md](/references/modularization.md). + +## Version Catalog + +Source of truth: `assets/libs.versions.toml.template`. Generate / update `gradle/libs.versions.toml` from it. + +Required: +- KSP for all annotation processing; kapt is forbidden. +- Room 3 via `androidx.room3` artifacts and the `androidx.room3` plugin; use `sqlite-bundled` with `BundledSQLiteDriver()` (configured by the `app.android.room` convention plugin). +- Compose compiler via the `kotlin-compose` plugin (Kotlin 2.0+). +- Use `unit-test` and `android-test` bundles for testing dependencies. + +## Convention Plugins + +Plugin sources live in `assets/convention/`: +- `*ConventionPlugin.kt` (incl. `PlayVitalsReportingConventionPlugin.kt` for root-only Play Vitals), `PlayVitalsReportingTask.kt`, and related `.kt` files. +- `config/` (`KotlinAndroid.kt`, `AndroidCompose.kt`, `Jacoco.kt`, …). +- `build.gradle.kts`, `QUICK_REFERENCE.md`. + +Copy them to `build-logic/convention/src/main/kotlin/`. + +### Android and Compose plugin order + +Required: +- Each module applies `com.android.application` or `com.android.library` at most once, from `app.android.application` / `app.android.library` (or from `app.android.feature`, which wraps the library plugins). `app.android.application.compose` and `app.android.library.compose` apply only `org.jetbrains.kotlin.plugin.compose` and read the existing Android extension; they must run **after** the base Android convention on that module (`assets/convention/QUICK_REFERENCE.md` shows alias order). + +### androidTest dependencies when androidTest is off + +Use when: Gradle warns that `androidTestImplementation` is ignored because androidTest is disabled. + +Required: enable `androidTest` for that module or stop adding `androidTestImplementation` from convention plugins for that module. Mismatched wiring produces configure-time noise only; fix by aligning source sets with dependency declarations. + +### Build Logic Setup + +`build-logic/convention/build.gradle.kts`: +```kotlin +plugins { + `kotlin-dsl` +} + +group = "com.example.buildlogic" + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +dependencies { + compileOnly(libs.android.gradlePlugin) + compileOnly(libs.kotlin.gradlePlugin) + compileOnly(libs.kotlin.composeGradlePlugin) + compileOnly(libs.ksp.gradlePlugin) + compileOnly(libs.room3.gradlePlugin) + implementation(libs.plugin.detekt) + implementation(libs.kotlinx.coroutines.core) +} + +gradlePlugin { + plugins { + register("androidApplication") { + id = "app.android.application" + implementationClass = "AndroidApplicationConventionPlugin" + } + register("androidApplicationCompose") { + id = "app.android.application.compose" + implementationClass = "AndroidApplicationComposeConventionPlugin" + } + register("androidApplicationBaselineProfile") { + id = "app.android.application.baseline" + implementationClass = "AndroidApplicationBaselineProfileConventionPlugin" + } + register("androidLibrary") { + id = "app.android.library" + implementationClass = "AndroidLibraryConventionPlugin" + } + register("androidLibraryCompose") { + id = "app.android.library.compose" + implementationClass = "AndroidLibraryComposeConventionPlugin" + } + register("androidFeature") { + id = "app.android.feature" + implementationClass = "AndroidFeatureConventionPlugin" + } + register("androidTest") { + id = "app.android.test" + implementationClass = "AndroidTestConventionPlugin" + } + register("androidRoom") { + id = "app.android.room" + implementationClass = "AndroidRoomConventionPlugin" + } + register("androidLint") { + id = "app.android.lint" + implementationClass = "AndroidLintConventionPlugin" + } + register("hilt") { + id = "app.hilt" + implementationClass = "HiltConventionPlugin" + } + register("detekt") { + id = "app.detekt" + implementationClass = "DetektConventionPlugin" + } + register("spotless") { + id = "app.spotless" + implementationClass = "SpotlessConventionPlugin" + } + register("jvmLibrary") { + id = "app.jvm.library" + implementationClass = "JvmLibraryConventionPlugin" + } + register("kotlinSerialization") { + id = "app.kotlin.serialization" + implementationClass = "KotlinSerializationConventionPlugin" + } + register("firebase") { + id = "app.firebase" + implementationClass = "FirebaseConventionPlugin" + } + register("playVitals") { + id = "app.play.vitals" + implementationClass = "PlayVitalsReportingConventionPlugin" + } + } +} +``` + +### Convention Plugin Files + +Implementations in `assets/convention/`: + +**Core Plugins:** +- `AndroidApplicationConventionPlugin.kt` - Root app module configuration +- `AndroidLibraryConventionPlugin.kt` - Android library modules +- `AndroidFeatureConventionPlugin.kt` - Feature modules with UI + ViewModel +- `AndroidTestConventionPlugin.kt` - Test-only modules + +**Compose & Build Plugins:** +- `AndroidApplicationComposeConventionPlugin.kt` - Compose for application +- `AndroidLibraryComposeConventionPlugin.kt` - Compose for libraries +- `AndroidApplicationBaselineProfileConventionPlugin.kt` - Baseline profiles +- `AndroidRoomConventionPlugin.kt` - Room 3 database (`androidx.room3`, KSP, `sqlite-bundled`) +- `AndroidLintConventionPlugin.kt` - Android Lint configuration + +**Testing & Quality Plugins:** +- `AndroidApplicationJacocoConventionPlugin.kt` - Code coverage for apps +- `AndroidLibraryJacocoConventionPlugin.kt` - Code coverage for libraries +- `HiltConventionPlugin.kt` - Hilt dependency injection +- `DetektConventionPlugin.kt` - Static analysis +- `SpotlessConventionPlugin.kt` - Code formatting + +**Other Plugins:** +- `JvmLibraryConventionPlugin.kt` - Pure Kotlin libraries +- `KotlinSerializationConventionPlugin.kt` - JSON serialization +- `FirebaseConventionPlugin.kt` - Firebase Crashlytics integration +- `SentryConventionPlugin.kt` - Sentry crash reporting integration +- `PlayVitalsReportingConventionPlugin.kt` - Optional root `playVitalsReport` task ([Play Vitals reporting](/references/android-performance.md)); pairs with `PlayVitalsReportingTask.kt` + +**Configuration Files (in config/ subdirectory):** +- `config/KotlinAndroid.kt` - Common Kotlin/Android setup +- `config/AndroidCompose.kt` - Compose configuration +- `config/ProjectExtensions.kt` - Version catalog access +- `config/GradleManagedDevices.kt` - Emulator configuration +- `config/AndroidInstrumentationTest.kt` - Test optimization +- `config/PrintApksTask.kt` - APK path printing +- `config/Jacoco.kt` - Code coverage configuration + +Setup and usage: `assets/convention/QUICK_REFERENCE.md`. + +### Registering a root-level reporting task (Play Vitals) + +Optional Play Vitals reporting ([android-performance.md](/references/android-performance.md)) ships as a convention plugin to copy into `build-logic`: + +| Source (copy to `build-logic/convention/src/main/kotlin/`) | Role | +|---------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------| +| [`assets/convention/PlayVitalsReportingConventionPlugin.kt`](../assets/convention/PlayVitalsReportingConventionPlugin.kt) | Registers **`playVitalsReport`** on **`rootProject` only** (`id`: **`app.play.vitals`**) | +| [`assets/convention/PlayVitalsReportingTask.kt`](../assets/convention/PlayVitalsReportingTask.kt) | Default task body: env check + lifecycle log; add **`PlayVitalsRepository`** per [android-performance.md](/references/android-performance.md) | + +The plugin is already wired in [`assets/convention/build.gradle.kts`](../assets/convention/build.gradle.kts) (`gradlePlugin { register("playVitals") { ... } }`). **`gradle/libs.versions.toml`** should include **`app-play-vitals`** from [`assets/libs.versions.toml.template`](../assets/libs.versions.toml.template) (`[plugins]`). + +Required: +- Apply `alias(libs.plugins.app.play.vitals)` in the **root** `build.gradle.kts` only. +- Forbidden in `app/build.gradle.kts` or feature modules. +- Forbidden inside `subprojects { }` / `allprojects { }` (duplicates / wrong scope). +- Wire CI to run `./gradlew playVitalsReport` on a schedule. + +Query payload and HTTP code: [android-performance.md](/references/android-performance.md). This section only covers Gradle wiring. + +## Module Build Files + +### App Module + +`app/build.gradle.kts`: +```kotlin +plugins { + alias(libs.plugins.app.android.application) + alias(libs.plugins.app.android.application.compose) + alias(libs.plugins.app.hilt) + alias(libs.plugins.app.detekt) + alias(libs.plugins.app.spotless) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "com.example.app" + + defaultConfig { + applicationId = "com.example.app" + versionCode = 1 + versionName = "1.0" + + // Enable multi-dex for larger apps + multiDexEnabled = true + } + + buildTypes { + debug { + applicationIdSuffix = ".debug" + isDebuggable = true + } + + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + + create("benchmark") { + initWith(getByName("release")) + signingConfig = signingConfigs.getByName("debug") + isDebuggable = false + } + } +} + +dependencies { + // Feature modules + implementation(project(":feature-auth")) + implementation(project(":feature-onboarding")) + implementation(project(":feature-profile")) + implementation(project(":feature-settings")) + + // Core modules + implementation(project(":core:domain")) + implementation(project(":core:data")) + implementation(project(":core:ui")) + implementation(project(":core:network")) + implementation(project(":core:database")) + implementation(project(":core:datastore")) + implementation(project(":core:common")) + + // Navigation3 for adaptive UI + implementation(libs.bundles.navigation3) + + // Adaptive layouts (NavigationSuiteScaffold, ListDetailPaneScaffold, SupportingPaneScaffold) + implementation(libs.bundles.adaptive) + + // Splash screen + implementation(libs.androidx.core.splashscreen) + + // WorkManager for background tasks + implementation(libs.androidx.work.runtime.ktx) + + // Testing + testImplementation(project(":core:testing")) + testImplementation(libs.bundles.unit.test) + androidTestImplementation(libs.bundles.android.test) +} +``` + +### Feature Module + +`feature-auth/build.gradle.kts`: +```kotlin +plugins { + alias(libs.plugins.app.android.feature) + alias(libs.plugins.app.detekt) + alias(libs.plugins.app.spotless) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "com.example.feature.auth" +} + +dependencies { + // Core module dependencies + implementation(project(":core:domain")) + implementation(project(":core:ui")) + + // Feature-specific dependencies + implementation(libs.androidx.constraintlayout.compose) + implementation(libs.coil.compose) + + // Testing + testImplementation(project(":core:testing")) + testImplementation(libs.bundles.unit.test) + androidTestImplementation(libs.bundles.android.test) +} +``` + +### Core Domain Module (Pure Kotlin) + +`core/domain/build.gradle.kts`: +```kotlin +plugins { + alias(libs.plugins.app.jvm.library) + alias(libs.plugins.app.detekt) + alias(libs.plugins.kotlin.serialization) +} + +dependencies { + // Pure Kotlin dependencies only + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization) + implementation(libs.kotlinx.collections.immutable) + implementation(libs.kotlinx.datetime) // For Clock.System and Duration API + + // DI + implementation(libs.java.inject) + + // Testing + testImplementation(libs.bundles.unit.test) +} +``` + +### Core Data Module + +`core/data/build.gradle.kts`: +```kotlin +plugins { + alias(libs.plugins.app.android.library) + alias(libs.plugins.app.hilt) + alias(libs.plugins.app.detekt) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "com.example.core.data" +} + +dependencies { + implementation(project(":core:domain")) + + // Data layer dependencies + implementation(project(":core:database")) + implementation(project(":core:network")) + implementation(project(":core:datastore")) + + // Data serialization + implementation(libs.kotlinx.serialization) + implementation(libs.retrofit2.kotlinx.serialization.converter) + + // Paging if needed + implementation(libs.androidx.paging.runtime) + implementation(libs.androidx.paging.compose) + + // Testing + testImplementation(project(":core:testing")) + testImplementation(libs.bundles.unit.test) +} +``` + +### Core UI Module + +`core/ui/build.gradle.kts`: +```kotlin +plugins { + alias(libs.plugins.app.android.library) + alias(libs.plugins.app.android.library.compose) + alias(libs.plugins.app.detekt) +} + +android { + namespace = "com.example.core.ui" +} + +dependencies { + implementation(project(":core:domain")) + + // Compose + implementation(libs.bundles.compose) + + // Image loading + implementation(libs.coil.compose) + implementation(libs.coil.network.okhttp) + + // Testing + testImplementation(libs.bundles.unit.test) + androidTestImplementation(libs.bundles.android.test) +} +``` + +### Core Network Module + +`core/network/build.gradle.kts`: +```kotlin +plugins { + alias(libs.plugins.app.android.library) + alias(libs.plugins.app.hilt) + alias(libs.plugins.app.detekt) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "com.example.core.network" +} + +dependencies { + implementation(project(":core:domain")) + + // Networking + implementation(libs.retrofit2) + implementation(libs.retrofit2.kotlinx.serialization.converter) + implementation(libs.okhttp3.logging.interceptor) + implementation(libs.kotlinx.serialization) + + // Testing + testImplementation(libs.bundles.unit.test) +} +``` + +### Core Database Module + +`core/database/build.gradle.kts`: +```kotlin +plugins { + alias(libs.plugins.app.android.library) + alias(libs.plugins.app.android.room) + alias(libs.plugins.app.hilt) + alias(libs.plugins.app.detekt) +} + +android { + namespace = "com.example.core.database" +} + +dependencies { + implementation(project(":core:domain")) + + // Room 3 runtime + sqlite-bundled + compiler via app.android.room convention + // Testing + testImplementation(libs.bundles.unit.test) +} +``` + +### Benchmark module + +**Use when:** macrobenchmark coverage from [android-performance.md](/references/android-performance.md) applies. Host it in a dedicated `:benchmark` test module. + +`benchmark/build.gradle.kts`: +```kotlin +plugins { + alias(libs.plugins.android.test) +} + +android { + namespace = "com.example.benchmark" + compileSdk { + version = release(libs.versions.compileSdk.get().toInt()) + } + + targetProjectPath = ":app" + testBuildType = "benchmark" + + defaultConfig { + minSdk = libs.findVersion("minSdk").get().toString().toInt() + testInstrumentationRunner = "androidx.benchmark.junit4.AndroidBenchmarkRunner" + } +} + +dependencies { + implementation(libs.androidx.benchmark.macro.junit4) + implementation(libs.androidx.junit) + implementation(libs.androidx.test.runner) + implementation(libs.androidx.test.uiautomator) +} +``` + +**Required:** `:app` declares a matching `benchmark` build type (`create("benchmark")` under Module Build Files). + +### Compose stability analyzer + +**Use when:** CI must gate composable stability per [android-performance.md → Compose Stability Validation](/references/android-performance.md#compose-stability-validation-optional). + +Root `build.gradle.kts`: +```kotlin +plugins { + alias(libs.plugins.compose.stability.analyzer) apply false +} +``` + +Module `build.gradle.kts` (app or heavy UI modules): +```kotlin +plugins { + alias(libs.plugins.app.android.application) + alias(libs.plugins.compose.stability.analyzer) +} + +composeStabilityAnalyzer { + stabilityValidation { + enabled.set(true) + outputDir.set(layout.projectDirectory.dir("stability")) + includeTests.set(false) + failOnStabilityChange.set(true) // Fail build on stability regressions + + // Allowed: exclude internal packages from fail-on-change + ignoredPackages.set(listOf("com.example.internal")) + ignoredClasses.set(listOf("PreviewComposables")) + } +} +``` + +## Code Quality (Detekt) + +Required: apply Detekt via the `app.detekt` convention plugin in every module. Setup, baselines, CI: [code-quality.md](/references/code-quality.md). + +## Build Variants & Optimization + +### Product Flavors for Different Environments + +`app/build.gradle.kts`: +```kotlin +android { + buildFeatures { + buildConfig = true // Required when using buildConfigField (off by default in AGP 8+) + } + + flavorDimensions += "environment" + + productFlavors { + create("development") { + dimension = "environment" + applicationIdSuffix = ".dev" + versionNameSuffix = "-dev" + buildConfigField("String", "BASE_URL", "\"https://api.dev.example.com/\"") + } + + create("staging") { + dimension = "environment" + applicationIdSuffix = ".staging" + versionNameSuffix = "-staging" + buildConfigField("String", "BASE_URL", "\"https://api.staging.example.com/\"") + } + + create("production") { + dimension = "environment" + buildConfigField("String", "BASE_URL", "\"https://api.example.com/\"") + } + } +} +``` + +**BuildConfig:** From AGP 8.0 onward, `BuildConfig` is not generated unless `buildFeatures.buildConfig` is enabled. You need this for `buildConfigField` values (e.g. `BuildConfig.BASE_URL`) and `BuildConfig.DEBUG`. + +**Variant names:** Gradle names variants `{productFlavor}{buildType}` with **capitalized** build type - for example `developmentDebug`, `stagingRelease`, `productionRelease`. + +**Common Gradle commands:** + +```bash +# List build-related tasks +./gradlew tasks --group="build" + +# Assemble or install a specific variant (flavor + build type) +./gradlew :app:assembleDevelopmentDebug +./gradlew :app:assembleStagingRelease +./gradlew :app:assembleProductionRelease +./gradlew :app:installDevelopmentDebug +./gradlew :app:installProductionRelease + +# All debug or all release variants across flavors +./gradlew :app:assembleDebug +./gradlew :app:assembleRelease + +# Deeper dependency / sync issues +./gradlew :app:dependencies +./gradlew assembleDevelopmentDebug --stacktrace +./gradlew --refresh-dependencies +``` + +**Flavor-specific source sets:** Optional overrides live next to `main` - for example `app/src/development/`, `app/src/staging/`, `app/src/production/` for resources or code only for that flavor; `app/src/debug/` and `app/src/release/` apply per build type across flavors. + +**Multiple flavor dimensions:** If you add another dimension (e.g. `tier` = `free` / `paid`), variants become combinations such as `developmentFreeDebug`. Cap flavor dimensions — each new dimension multiplies variant count and CI time. + +### Build Optimization Configuration + +`gradle.properties`: +```properties +# Build performance +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 + +# Configuration cache +org.gradle.configuration-cache=true + +# Android build optimization +android.useAndroidX=true +kotlin.incremental=true +kotlin.caching.enabled=true + +# Module metadata +android.nonTransitiveRClass=true + +# KSP optimization +ksp.incremental=true +ksp.incremental.log=false +``` + +### Non-Transitive R Classes + +With `android.nonTransitiveRClass=true`, each module generates its own R class containing **only its own resources**. This improves build performance but requires explicit imports when accessing resources from other modules. + +**Key implications:** + +1. **Each module has its own R class** with its full package name: + ```kotlin + // In :feature:products module + com.example.feature.products.R + + // In :core:ui module + com.example.core.ui.R + ``` + +2. **Unqualified `R` may not resolve** if your file is in a sub-package: + ```kotlin + // File: feature/products/presentation/detail/ProductDetailView.kt + // Package: com.example.feature.products.presentation.detail + + // This may fail: + stringResource(R.string.product_title) // WRONG: Unresolved reference + + // Fix: Import the module's R class explicitly + import com.example.feature.products.R + stringResource(R.string.product_title) // CORRECT: Works + ``` + +3. **Cross-module resources require import aliases**: + ```kotlin + // Accessing strings from core:ui in feature:products + import com.example.core.ui.R as CoreUiR + + @Composable + fun ErrorMessage() { + Text(stringResource(CoreUiR.string.error_unknown)) + Text(stringResource(CoreUiR.string.error_network)) + } + ``` + +4. **Fully qualified references** (alternative to imports): + ```kotlin + Text(stringResource(com.example.core.ui.R.string.loading)) + ``` + +**Required:** +- Use import aliases (`as CoreUiR`) when one file pulls strings from multiple foreign modules. +- Group cross-module resource imports at the top of the file. +- String ownership rules: [android-i18n.md → String resource ownership](/references/android-i18n.md#string-resource-ownership). + +### R8 / ProGuard Configuration + +R8 is the default code shrinker and obfuscator in AGP. Enable it in release builds: + +```kotlin +buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } +} +``` + +Copy `assets/proguard-rules.pro.template` to `app/proguard-rules.pro` and adjust `com.example.*` package names to match your project. The template includes rules for every library in the version catalog. + +**Required:** +- Rely on AndroidX/Jetpack consumer rules inside AARs; add manual keep rules only when library docs or R8 full-mode errors demand it. +- Ship Retrofit keep rules for R8 full-mode (`Proxy`-generated interfaces stay invisible to static analysis). +- Add `-dontwarn` for Tink error-prone annotations when using `EncryptedSharedPreferences`. +- Keep SQLCipher native methods in shrinker output. +- Upload `mapping.txt` to Crashlytics/Sentry so release stacks decode (Gradle plugins wire this when configured). + +**Debugging shrunk builds:** + +```bash +# Build release with full R8 output +./gradlew assembleRelease + +# Decode an obfuscated stack trace +retrace build/outputs/mapping/release/mapping.txt stacktrace.txt +``` + +Check `build/outputs/mapping/release/` for the mapping file after each release build. + +See [android-security.md](/references/android-security.md#proguard--r8-hardening) for security-specific hardening rules (log stripping, aggressive obfuscation, manifest settings). + +### R8 Keep-Rules Audit + +Run when `proguard-rules.pro` grows past ~50 lines, release APK/AAB size regresses, or a release-only crash points at a missing class/member. Steps are ordered worst-impact first; skip any step whose rule class does not appear in the file. + +**Step 1 - Drop redundant library rules.** The libraries below ship consumer rules inside the AAR/JAR. App-side duplicates only mask narrower rules - delete them first. + +| Library group | App-side rules needed? | +|--------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------| +| AndroidX / Jetpack (lifecycle, room3, paging, work, …) | No. Consumer rules are bundled. | +| Kotlin stdlib, kotlinx.coroutines, kotlinx.collections | No. Only `-dontwarn kotlinx.coroutines.**` for residual warnings. | +| kotlinx.serialization | Library bundles rules since 1.6+. Keep only the **`@Serializable` generic-parameter** rules (R8 full-mode strips classes used only as `List`). | +| Retrofit / OkHttp | Retrofit needs the `@retrofit2.http.*` interface keeps for R8 full-mode (Proxy). OkHttp 5.x: only `-dontwarn` for optional Conscrypt/BouncyCastle. | +| Gson / Moshi (codegen) | No. Codegen variants ship rules. Reflective Gson **does** need `-keep` on the model package. | +| Hilt / Dagger | No. Only `-dontwarn dagger.hilt.internal.**` and project-level DI keep if you reflect into it. | +| Google Play services (incl. Play Integrity, Play Core) | No. Only `-dontwarn` for optional sub-packages. | +| Firebase SDKs | No. Mapping upload is handled by the Gradle plugin. | +| Coil 3, Compose, Compose-runtime | No (Compose-stability annotations are the only edge case worth keeping). | + +If a release build fails after deleting one of the above, the failure points to a **reflection** site in app code (see step 4), not a missing library keep. + +**Step 2 - Score remaining rules by impact (broad → narrow).** Use the narrowest rule that works. Top-row rules are size-regression suspects: + +| Tier (worst → best) | Pattern | Effect | +|---------------------|-----------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------| +| 1 - Package-wide | `-keep class com.example.** { *; }` | Disables shrinking, optimization, and obfuscation for the whole tree. Forbidden in app code unless step 4 cannot narrow it. | +| 2 - Class-wide | `-keep class com.example.Foo { *; }` | Keeps every member; disables member-level optimization for that class. | +| 3 - Method/field | `-keepclassmembers class com.example.Foo { void bar(); }` | Keeps only what reflection touches; R8 shrinks/optimizes the rest. | +| 4 - Conditional | `-if @MyAnnotation class ** -keep class <1> { *; }` | Required form for annotation-driven reflection. | + +**Step 3 - Detect subsuming rules and remove the broader half.** When two rules overlap, keep only the narrower one: + +- `-keep class com.example.Foo { *; }` subsumes any `-keepclassmembers class com.example.Foo { … }` - **delete the class-wide rule**, keep the member rule. +- `-keep class com.example.** { *; }` subsumes every per-class rule under that package - **delete the package-wide rule**, keep the per-class rules. +- A conditional `-if … -keep <1>` subsumes the equivalent unconditional `-keep` for the same class - delete the unconditional one. + +R8 emits no "redundant rule" report. To verify a suspected redundancy, comment the broader rule out, run `./gradlew assembleRelease`, and confirm `mapping.txt` still contains the narrower-kept symbol. + +**Step 4 - Narrow reflection-driven keeps.** For every remaining package- or class-wide rule, locate the reflection site (search for `Class.forName`, `::class.java`, `getDeclaredMethod`, `getDeclaredField`, JNI symbol lookups, `META-INF/services/` entries, Gson `TypeToken`, Moshi adapter lookups, Retrofit `Proxy`). Replace the broad rule with one that targets only the reflected members: + +```proguard +# Before: package-wide +-keep class com.example.api.models.** { *; } + +# After: only what Gson reads via reflection +-keep class com.example.api.models.** { + (); + ; +} + +# Before: class-wide because one method is called via Class.forName +-keep class com.example.plugins.AnalyticsPlugin { *; } + +# After: only the constructor + entry point +-keep class com.example.plugins.AnalyticsPlugin { + (); + public void initialize(android.content.Context); +} +``` + +For annotation-driven reflection, use `-if @YourAnnotation class **` so the rule scales as new annotated classes are added. + +**Step 5 - AGP 9 default optimizations.** AGP 9 enables additional R8 optimizations by default. Re-run steps 1-4 after every AGP upgrade and every major library bump. Track release APK/AAB size as a CI metric to surface silent regressions. + +**Final guardrail.** Before shipping any `proguard-rules.pro` change: + +1. `./gradlew assembleRelease` (or `bundleRelease`) succeeds. +2. Run a UI Automator smoke test over the packages whose rules changed (see [testing.md](/references/testing.md)). +3. Diff `mapping.txt` line count against the previous release. Drops are the win signal; jumps mean a broader keep slipped in. + +## Build Performance + +### Settings Configuration + +Check `assets/settings.gradle.kts.template` as the source of truth for settings setup, +module includes, and repository configuration. + +### Root Build File + +`build.gradle.kts`: +```kotlin +// Top-level build. Repositories are configured in settings.gradle.kts via dependencyResolutionManagement. +// AGP 9+ ships built-in Kotlin support; do not apply org.jetbrains.kotlin.android. +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.android.test) apply false + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.hilt) apply false + alias(libs.plugins.detekt) apply false + alias(libs.plugins.spotless) apply false +} + +// Apply spotless formatting to root project +plugins.apply(libs.plugins.spotless.get().pluginId) + +configure { + kotlin { + target("**/*.kt") + targetExclude("**/build/**") + ktlint(libs.versions.ktlint.get()) + .editorConfigOverride( + mapOf( + "indent_size" to "4", + "continuation_indent_size" to "4", + "max_line_length" to "120", + "disabled_rules" to "no-wildcard-imports" + ) + ) + licenseHeaderFile(rootProject.file("spotless/copyright.kt")) + } + + kotlinGradle { + target("**/*.gradle.kts") + ktlint(libs.versions.ktlint.get()) + } +} +``` + +### Build Cache Configuration + +Create `gradle/init.gradle.kts` for team-wide build optimization: +```kotlin +gradle.settingsEvaluated { + // Enable build cache for all projects + buildCache { + local { + isEnabled = true + directory = File(rootDir, ".gradle/build-cache") + removeUnusedEntriesAfterDays = 7 + } + + remote { + isEnabled = false // Set to true for CI/CD shared cache + url = uri("https://example.com/cache/") + isPush = true + } + } +} +``` + +### Optimization Workflow + +Required: change one variable at a time; measure before and after. + +1. Baseline: `./gradlew clean assembleDebug` and an incremental build. +2. Build Scan: `./gradlew assembleDebug --scan`. +3. In the scan, identify the slow phase (Initialization / Configuration / Execution) under **Performance → Build timeline**. +4. Apply one change. +5. Re-measure; revert if no improvement. + +Local-only profile (no upload): `./gradlew assembleDebug --profile` → `build/reports/profile/`. + +### Lazy Task Configuration + +Required: `tasks.register` for every custom task; `tasks.create` is forbidden (eagerly configures on every build). + +```kotlin +// Bad +tasks.create("generateBuildInfo") { + doLast { /* ... */ } +} + +// Good +tasks.register("generateBuildInfo") { + doLast { /* ... */ } +} +``` + +### Avoid I/O During Configuration + +Forbidden in configuration phase: `File.readText()`, network calls, `exec { }`. They run every build and break the configuration cache. Defer via `providers`. + +```kotlin +// Bad +val version = file("version.txt").readText() + +// Good +val version = providers.fileContents(layout.projectDirectory.file("version.txt")).asText +``` + +```kotlin +// Bad +val gitHash = Runtime.getRuntime().exec("git rev-parse --short HEAD") + .inputStream.bufferedReader().readText().trim() + +// Good +val gitHash = providers.exec { + commandLine("git", "rev-parse", "--short", "HEAD") +}.standardOutput.asText.map { it.trim() } +``` + +### Pin Dependency Versions + +Forbidden: dynamic versions (`1.+`, `latest.release`, `-SNAPSHOT`). Always pin via the version catalog. + +```kotlin +// Bad +implementation("com.example:lib:1.0.+") + +// Good +implementation(libs.example.lib) +``` + +### Bottleneck Troubleshooting + +| Symptom | Fix | +|-------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| +| Slow configuration phase | Use `tasks.register`; defer I/O via `providers`; move plugins into convention plugins; remove `subprojects { }` / `allprojects { }`. | +| Slow execution phase | Migrate kapt → KSP ([dependencies.md](/references/dependencies.md)); enable `org.gradle.caching=true`; enable `org.gradle.parallel=true`; raise `-Xmx`. | +| Slow dependency resolution | Pin exact versions in the catalog; order `google()` before `mavenCentral()`; remove unused repos; ensure `org.gradle.caching=true`. | + +## Rules + +Required: +- Centralize all versions in `gradle/libs.versions.toml`. +- Extract every reusable build configuration into a convention plugin. +- Use KSP for annotation processing ([dependencies.md](/references/dependencies.md)). +- Enable type-safe project accessors and local + remote build cache. +- Apply Compose-only UI; no View binding, no legacy `View` system. + +Forbidden: +- Dynamic versions (`1.+`, `latest.release`, `-SNAPSHOT`). +- Inline build logic duplicated across modules instead of a convention plugin. +- I/O, `exec`, or `Runtime.getRuntime()` during the configuration phase. + +## Common Gradle Commands + +```bash +# Clean build +./gradlew clean + +# Build debug APK +./gradlew assembleDebug + +# Build release APK +./gradlew assembleRelease + +# Run unit tests +./gradlew test + +# Run instrumented tests +./gradlew connectedAndroidTest + +# Run detekt +./gradlew detekt + +# Run spotless format check +./gradlew spotlessCheck + +# Apply spotless formatting +./gradlew spotlessApply + +# Generate dependency report +./gradlew dependencies + +# Profile build +./gradlew assembleDebug --profile + +# Build with configuration cache +./gradlew assembleDebug --configuration-cache + +# Build all variants +./gradlew assemble +``` diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/references/kotlin-delegation.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/kotlin-delegation.md new file mode 100644 index 000000000..a6044bee0 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/kotlin-delegation.md @@ -0,0 +1,750 @@ +# Kotlin Delegation (Composition over Inheritance) + +Use Kotlin's class and property delegation (`by`) to share behavior across ViewModels and other classes. Forbidden: open base classes (`BaseViewModel`, `BaseActivity`, etc.) for cross-cutting concerns like logging, validation, crash reporting, or feature flags. + +## Table of Contents +1. [Delegation routing](#delegation-routing) +2. [Class Delegation](#class-delegation) +3. [Property Delegation](#property-delegation) +4. [Advanced Patterns](#advanced-patterns) +5. [Testing with Delegation](#testing-with-delegation) +6. [Rules](#rules) + +## Delegation routing + +**Use when:** + +- Shared behavior across multiple ViewModels or classes +- Behavior not tied to Android framework inheritance requirements +- Logic that benefits from clear interfaces and DI (e.g., validators, analytics, feature flags) +- Layered behavior (decorator pattern) +- State management in ViewModels (`by mutableStateOf`) + +**Forbidden:** + +- Single-use logic with no reuse potential +- Delegation that only adds indirection +- Framework-required inheritance (e.g., `Activity`, `Application`, `ViewModel` itself) +- Hot paths where delegation measurably regresses performance (profile before changing) + +## Class Delegation + +### Basic Pattern + +```kotlin +interface Logger { + fun log(message: String) +} + +class ConsoleLogger : Logger { + override fun log(message: String) { + println("LOG: $message") + } +} + +class ExampleViewModel( + private val savedStateHandle: SavedStateHandle, + logger: Logger // No private - delegated only +) : ViewModel(), Logger by logger { + fun runAction() { + log("Action started") // Delegated method + } +} +``` + +### Inheritance vs Delegation Comparison + +```kotlin +// WRONG: Deep inheritance hierarchy +abstract class BaseViewModel : ViewModel() { + abstract fun log(message: String) + abstract fun validateEmail(email: String): String? + abstract fun trackEvent(name: String) + + fun commonLogic() { + log("Common logic executed") + } +} + +class LoginViewModel : BaseViewModel() { + override fun log(message: String) { + println("LOG: $message") + } + + override fun validateEmail(email: String): String? { + return if (email.contains("@")) null else "Invalid email" + } + + override fun trackEvent(name: String) { + // Analytics logic + } + + // Tightly coupled to base class +} + +// CORRECT: Composition with delegation +interface Logger { + fun log(message: String) +} + +interface FormValidator { + fun validateEmail(email: String): ValidationResult +} + +interface Analytics { + fun trackEvent(name: String) +} + +@HiltViewModel +class LoginViewModel @Inject constructor( + logger: Logger, + validator: FormValidator, + analytics: Analytics +) : ViewModel(), + Logger by logger, + FormValidator by validator, + Analytics by analytics { + + // Independent, testable, swappable behavior + fun onLoginClicked(email: String) { + val result = validateEmail(email) + when (result) { + is ValidationResult.Valid -> { + log("Login attempt for: $email") + trackEvent("login_clicked") + } + is ValidationResult.Invalid -> { + log("Validation failed: ${result.error}") + } + } + } +} +``` + +### Multiple Interface Delegation + +```kotlin +@HiltViewModel +class AuthViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, + crashReporter: CrashReporter, // No private - delegated only + logger: CrashlyticsStateLogger // No private - delegated only +) : ViewModel(), + CrashReporter by crashReporter, + CrashlyticsStateLogger by logger { + + fun onRoleSelected(role: String) { + logUiState("auth_role", role) // From CrashlyticsStateLogger + logAction("Auth role selected: $role") // From CrashlyticsStateLogger + } + + fun onLoginFailed(error: Throwable) { + recordException( // From CrashReporter + error, + mapOf("action" to "login", "screen" to "auth") + ) + } +} +``` + +### Overriding Delegated Methods + +For the `CrashReporter` interface definition and implementations (`FirebaseCrashReporter`, `SentryCrashReporter`, `PrivacyAwareCrashReporter`), see `references/crashlytics.md` → "Provider-Agnostic Interface" and "Data Scrubbing (Privacy/GDPR)" sections. + +Example of overriding delegated methods: + +```kotlin +// Decorator pattern with delegation +// (Full CrashReporter interface in references/crashlytics.md → "Provider-Agnostic Interface") +class PrivacyAwareCrashReporter( + crashReporter: CrashReporter // No private - delegated only +) : CrashReporter by crashReporter { + + // Override to add custom behavior + override fun recordException( + throwable: Throwable, + context: Map + ) { + // Custom pre-processing (scrub sensitive data) + val scrubbedContext = scrubbingLogic(context) + + // Call delegated implementation + super.recordException(throwable, scrubbedContext) + } + + // Other methods (setUserId, setUserProperty, log) are fully delegated +} +``` + +### Form Validation with Sealed Errors + +```kotlin +// core/domain - Sealed error types +sealed class ValidationError { + data object InvalidEmail : ValidationError() + data object PasswordTooShort : ValidationError() + data object PasswordTooWeak : ValidationError() + data object PasswordMismatch : ValidationError() +} + +sealed class ValidationResult { + data object Valid : ValidationResult() + data class Invalid(val error: ValidationError) : ValidationResult() +} + +// core/domain - Validator interface +interface FormValidator { + fun validateEmail(email: String): ValidationResult + fun validatePassword(password: String): ValidationResult + fun validatePasswordMatch(password: String, confirmPassword: String): ValidationResult +} + +// core/data - Implementation +class DefaultFormValidator @Inject constructor() : FormValidator { + + override fun validateEmail(email: String): ValidationResult = + if (email.matches(EMAIL_REGEX)) { + ValidationResult.Valid + } else { + ValidationResult.Invalid(ValidationError.InvalidEmail) + } + + override fun validatePassword(password: String): ValidationResult = + when { + password.length < 8 -> ValidationResult.Invalid(ValidationError.PasswordTooShort) + !password.matches(PASSWORD_STRENGTH_REGEX) -> ValidationResult.Invalid(ValidationError.PasswordTooWeak) + else -> ValidationResult.Valid + } + + override fun validatePasswordMatch(password: String, confirmPassword: String): ValidationResult = + if (password == confirmPassword) { + ValidationResult.Valid + } else { + ValidationResult.Invalid(ValidationError.PasswordMismatch) + } + + companion object { + private val EMAIL_REGEX = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$") + private val PASSWORD_STRENGTH_REGEX = Regex("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$") + } +} + +// feature/auth - ViewModel with delegation +@HiltViewModel +class RegisterViewModel @Inject constructor( + private val registerUseCase: RegisterUseCase, + private val savedStateHandle: SavedStateHandle, + validator: FormValidator // No private - delegated only +) : ViewModel(), FormValidator by validator { + + private val _formState = MutableStateFlow(RegisterFormState()) + val formState: StateFlow = _formState.asStateFlow() + + fun onEmailChanged(email: String) { + _formState.update { it.copy(email = email) } + + val result = validateEmail(email) // Delegated method + _formState.update { + when (result) { + is ValidationResult.Valid -> it.copy(emailError = null) + is ValidationResult.Invalid -> it.copy(emailError = result.error.toMessage()) + } + } + } + + fun onPasswordChanged(password: String) { + _formState.update { it.copy(password = password) } + + val result = validatePassword(password) // Delegated method + _formState.update { + when (result) { + is ValidationResult.Valid -> it.copy(passwordError = null) + is ValidationResult.Invalid -> it.copy(passwordError = result.error.toMessage()) + } + } + } + + private fun ValidationError.toMessage(): String = when (this) { + ValidationError.InvalidEmail -> "Invalid email format" + ValidationError.PasswordTooShort -> "Password must be at least 8 characters" + ValidationError.PasswordTooWeak -> "Password must contain uppercase, lowercase, and number" + ValidationError.PasswordMismatch -> "Passwords do not match" + } +} +``` + +## Property Delegation + +### Lazy Initialization + +```kotlin +class UserRepository @Inject constructor( + private val database: UserDatabase, + private val api: UserApi +) { + // Expensive object initialized only when first accessed + private val userCache: MutableMap by lazy { + mutableMapOf() + } + + suspend fun getUser(userId: String): User = + userCache.getOrPut(userId) { + api.getUser(userId) + } +} + +// Analytics initialized only when needed +class AnalyticsManager @Inject constructor( + @ApplicationContext private val context: Context +) { + private val analytics: FirebaseAnalytics by lazy { + FirebaseAnalytics.getInstance(context) + } + + fun logEvent(name: String) { + analytics.logEvent(name, null) + } +} +``` + +### State Delegation with mutableStateOf + +```kotlin +@Stable +class SearchState { + var query by mutableStateOf("") + private set + + var isLoading by mutableStateOf(false) + private set + + var results by mutableStateOf>(emptyList()) + private set + + var error by mutableStateOf(null) + private set + + fun updateQuery(newQuery: String) { + query = newQuery + } + + fun setLoading(loading: Boolean) { + isLoading = loading + } + + fun setResults(newResults: List) { + results = newResults + error = null + } + + fun setError(message: String) { + error = message + results = emptyList() + } +} + +@HiltViewModel +class SearchViewModel @Inject constructor( + private val searchRepository: SearchRepository +) : ViewModel() { + val state = SearchState() + + fun onQueryChanged(query: String) { + state.updateQuery(query) + search() + } + + private fun search() { + viewModelScope.launch { + state.setLoading(true) + searchRepository.search(state.query).fold( + onSuccess = { state.setResults(it) }, + onFailure = { state.setError(it.message ?: "Unknown error") } + ) + state.setLoading(false) + } + } +} +``` + +### Observable Properties (Custom Delegates) + +```kotlin +// Custom delegate for observable properties +class Observable( + private var value: T, + private val onChange: (T) -> Unit +) { + operator fun getValue(thisRef: Any?, property: KProperty<*>): T = value + + operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: T) { + if (value != newValue) { + value = newValue + onChange(newValue) + } + } +} + +fun observable(initialValue: T, onChange: (T) -> Unit) = + Observable(initialValue, onChange) + +// Usage +class SettingsViewModel @Inject constructor( + private val settingsRepository: SettingsRepository +) : ViewModel() { + + var darkMode by observable(false) { newValue -> + // Called whenever darkMode changes + viewModelScope.launch { + settingsRepository.saveDarkMode(newValue) + } + } + + var notificationsEnabled by observable(true) { newValue -> + viewModelScope.launch { + settingsRepository.saveNotifications(newValue) + } + } +} +``` + +## Advanced Patterns + +### Complex Real-World Example + +[`crashlytics.md`](/references/crashlytics.md) defines the `CrashReporter` surface and provider-agnostic implementations. + +```kotlin +// Interfaces for different concerns +interface Logger { + fun log(message: String) + fun logError(message: String, throwable: Throwable) +} + +// CrashReporter interface definition in references/crashlytics.md → "Provider-Agnostic Interface" +// interface CrashReporter { ... } + +interface Analytics { + fun trackEvent(name: String, properties: Map = emptyMap()) +} + +interface FormValidator { + fun validateEmail(email: String): ValidationResult + fun validatePassword(password: String): ValidationResult +} + +// ViewModel composing all behaviors +@HiltViewModel +class AuthViewModel @Inject constructor( + private val authRepository: AuthRepository, + private val savedStateHandle: SavedStateHandle, + logger: Logger, + crashReporter: CrashReporter, + analytics: Analytics, + validator: FormValidator +) : ViewModel(), + Logger by logger, + CrashReporter by crashReporter, + Analytics by analytics, + FormValidator by validator { + + private val _uiState = MutableStateFlow(AuthUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onLoginClicked(email: String, password: String) { + viewModelScope.launch { + // Validation (delegated) + val emailResult = validateEmail(email) + val passwordResult = validatePassword(password) + + if (emailResult is ValidationResult.Invalid) { + logError("Email validation failed", Exception("Invalid: ${emailResult.error}")) + _uiState.update { it.copy(emailError = emailResult.error.toMessage()) } + return@launch + } + + if (passwordResult is ValidationResult.Invalid) { + logError("Password validation failed", Exception("Invalid: ${passwordResult.error}")) + _uiState.update { it.copy(passwordError = passwordResult.error.toMessage()) } + return@launch + } + + // Logging (delegated) + log("Login attempt started for: $email") + + // Set user context (delegated) + setUserId(email) + setUserProperty("login_method", "email") + + // Analytics (delegated) + trackEvent("login_attempt", mapOf("method" to "email")) + + _uiState.update { it.copy(isLoading = true) } + + authRepository.login(email, password).fold( + onSuccess = { token -> + log("Login successful") + trackEvent("login_success") + _uiState.update { it.copy(isLoading = false, success = true) } + }, + onFailure = { error -> + logError("Login failed", error) + recordException(error, mapOf( // Crash reporting (delegated) + "screen" to "login", + "action" to "login_clicked", + "email_domain" to email.substringAfter("@") + )) + trackEvent("login_failure", mapOf("error" to error.message.orEmpty())) + _uiState.update { it.copy(isLoading = false, error = error.message) } + } + ) + } + } +} +``` + +## Testing with Delegation + +### Creating Test Fakes + +[`crashlytics.md`](/references/crashlytics.md) carries the `CrashReporter` contract used by these fakes. + +```kotlin +// Test fakes for delegated interfaces +class FakeLogger : Logger { + val messages = mutableListOf() + val errors = mutableListOf>() + + override fun log(message: String) { + messages.add(message) + } + + override fun logError(message: String, throwable: Throwable) { + errors.add(message to throwable) + } +} + +// Test fake for CrashReporter (interface in references/crashlytics.md → "Provider-Agnostic Interface") +class FakeCrashReporter : CrashReporter { + var userId: String? = null + val properties = mutableMapOf() + val logMessages = mutableListOf() + val exceptions = mutableListOf>>() + + override fun setUserId(id: String?) { + userId = id + } + + override fun setUserProperty(key: String, value: String) { + properties[key] = value + } + + override fun log(message: String) { + logMessages.add(message) + } + + override fun recordException(throwable: Throwable, context: Map) { + exceptions.add(throwable to context) + } +} + +class FakeAnalytics : Analytics { + val events = mutableListOf>>() + + override fun trackEvent(name: String, properties: Map) { + events.add(name to properties) + } +} + +class FakeFormValidator( + private val emailResult: ValidationResult = ValidationResult.Valid, + private val passwordResult: ValidationResult = ValidationResult.Valid +) : FormValidator { + override fun validateEmail(email: String): ValidationResult = emailResult + override fun validatePassword(password: String): ValidationResult = passwordResult +} + +// Test using fakes +@Test +fun `login tracks analytics event on success`() = runTest { + val fakeLogger = FakeLogger() + val fakeAnalytics = FakeAnalytics() + val fakeValidator = FakeFormValidator() + val fakeCrashReporter = FakeCrashReporter() + val fakeAuthRepository = FakeAuthRepository() + + val viewModel = AuthViewModel( + authRepository = fakeAuthRepository, + savedStateHandle = SavedStateHandle(), + logger = fakeLogger, + crashReporter = fakeCrashReporter, + analytics = fakeAnalytics, + validator = fakeValidator + ) + + viewModel.onLoginClicked("test@example.com", "password123") + + advanceUntilIdle() + + // Verify analytics was tracked via delegation + assertThat(fakeAnalytics.events).contains( + "login_attempt" to mapOf("method" to "email") + ) + assertThat(fakeAnalytics.events).contains( + "login_success" to emptyMap() + ) + + // Verify logging happened + assertThat(fakeLogger.messages).contains("Login successful") +} + +@Test +fun `login records crash on failure`() = runTest { + val fakeCrashReporter = FakeCrashReporter() + val fakeAuthRepository = FakeAuthRepository(shouldFail = true) + + val viewModel = AuthViewModel( + authRepository = fakeAuthRepository, + savedStateHandle = SavedStateHandle(), + logger = FakeLogger(), + crashReporter = fakeCrashReporter, + analytics = FakeAnalytics(), + validator = FakeFormValidator() + ) + + viewModel.onLoginClicked("test@example.com", "password123") + + advanceUntilIdle() + + // Verify crash was reported via delegation + assertThat(fakeCrashReporter.exceptions).hasSize(1) + assertThat(fakeCrashReporter.exceptions[0].second).containsEntry("screen", "login") + assertThat(fakeCrashReporter.exceptions[0].second).containsEntry("email_domain", "example.com") + + // Verify user context was set + assertThat(fakeCrashReporter.userId).isEqualTo("test@example.com") + assertThat(fakeCrashReporter.properties).containsEntry("login_method", "email") +} +``` + +## Rules + +### Interface shape + +- Cap interfaces at roughly five methods; split larger surfaces. +- Delegate to interfaces, not concrete implementations, so fakes swap cleanly. +- Return `ValidationResult` or sealed error types instead of nullable strings for validation failures. + +### Implementation + +- **No `private` on delegated parameters:** keeps delegation explicit and prevents bypassing the `by` target. +- Override delegated methods by calling `super.method()` unless the override fully replaces behavior. +- Inject delegates through Hilt instead of constructing graphs manually. +- Add a one-line comment only when the delegate wiring is not obvious from types alone. + +### Auditing Existing Base Classes + +When migrating from inheritance-based patterns to delegation, audit base classes for dead code: + +**Common dead code in base classes:** + +1. **Methods never overridden or called by subclasses** + ```kotlin + // BaseViewModel.kt + abstract class BaseViewModel : ViewModel() { + // WRONG: Dead code: No subclass ever uses this + protected fun handleFailure(throwable: Throwable) { + viewModelScope.launch { + _failure.emit(throwable) + } + } + + private val _failure = MutableSharedFlow() + val failure: SharedFlow = _failure.asSharedFlow() + } + ``` + **Fix:** Delete unused methods and their associated state. If no subclass collects `failure`, remove it entirely. + +2. **`@Inject` fields never accessed** + ```kotlin + // Application class or BaseViewModel + class MyApplication : Application() { + @Inject lateinit var resources: Resources // WRONG: Never used + @Inject lateinit var crashReporter: CrashReporter // CORRECT: Used in subclasses + } + ``` + **Fix:** Remove unused `@Inject` fields. They compile fine but add unnecessary dependencies. + +3. **Channels/Flows never collected by UI** + ```kotlin + // BaseViewModel.kt + abstract class BaseViewModel : ViewModel() { + private val _failure = MutableSharedFlow() + val failure: SharedFlow = _failure.asSharedFlow() // WRONG: No screen collects this + } + ``` + **Fix:** Search codebase for `.collect` or `collectAsStateWithLifecycle()` on this flow. If none exist, delete it. + +4. **Methods only used in removed code** + ```kotlin + // BaseViewModel.kt + abstract class BaseViewModel : ViewModel() { + // WRONG: Only called by handleFailure(), which is also unused + protected fun logError(throwable: Throwable) { + crashReporter.recordException(throwable) + } + } + ``` + **Fix:** Delete transitively unused methods. Trace back from public APIs. + +**Audit process:** +1. **Find all subclasses**: Search for `: BaseViewModel` or `: BaseClass` +2. **Check method usage**: For each method in the base class, verify it's called by at least one subclass +3. **Check field access**: For each field/property, verify it's accessed by at least one subclass +4. **Check flow collection**: For each `Flow`/`Channel`, verify UI collects from it +5. **Delete aggressively**: Dead code in base classes is hard to spot because it compiles fine and appears intentional + +**Example audit:** +```kotlin +// Before (BaseViewModel with dead code) +abstract class BaseViewModel : ViewModel() { + @Inject lateinit var resources: Resources // Never used + + private val _failure = MutableSharedFlow() + val failure: SharedFlow = _failure.asSharedFlow() // Never collected + + protected fun handleFailure(throwable: Throwable) { // Never called + viewModelScope.launch { + _failure.emit(throwable) + } + } +} + +// After (migrate to delegation, remove dead code) +class AuthViewModel @Inject constructor( + crashReporter: CrashReporter // Explicit dependency, used via delegation +) : ViewModel(), CrashReporter by crashReporter { + // Dead code eliminated by not using base class +} +``` + +### Testing +- **Create simple fakes**: Test fakes should be straightforward, not mocks. +- **Verify delegated behavior**: Test that delegated methods are called correctly. +- **Test overrides separately**: If you override a delegated method, test the custom behavior. + +### Performance +- **Negligible overhead**: Delegation creates minimal wrapper objects with no measurable impact. +- **Don't over-optimize**: Use delegation freely; it's a design tool, not a performance concern. + +## Related References + +- **Crash Reporting**: For `CrashReporter` interface and implementations, see `references/crashlytics.md` +- **Design Patterns**: See `references/design-patterns.md` for Decorator pattern with delegation +- **ViewModel Patterns**: Use with ViewModel patterns in `references/compose-patterns.md` +- **Architecture**: `references/architecture.md` + +## Sources + +- https://kotlinlang.org/docs/delegation.html +- https://kotlinlang.org/docs/delegated-properties.html diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/references/kotlin-patterns.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/kotlin-patterns.md new file mode 100644 index 000000000..ab5f3ccb2 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/kotlin-patterns.md @@ -0,0 +1,1049 @@ +# Kotlin Patterns + +Intermediate and advanced Kotlin rules for Android. Basic language features (data classes, null safety, scope functions) are assumed. Each item ships with a runnable example; large topics link to dedicated references. + +Time-related examples use `kotlin.time.Duration` and `kotlinx.datetime.Clock`. Do not use `java.util.Date`/`Calendar` or `Long` millis in domain code. + +## Table of Contents +1. [Kotlin 2.x and the K2 Compiler](#kotlin-2x-and-the-k2-compiler) +2. [Delegation (Composition over Inheritance)](#delegation-composition-over-inheritance) +3. [Pragmatic layering & import hygiene](#pragmatic-layering--import-hygiene) +4. [Collection APIs](#collection-apis) +5. [Sealed Classes & Exhaustive When](#sealed-classes--exhaustive-when) +6. [Generics & Reified Types](#generics--reified-types) +7. [Extension Functions](#extension-functions) +8. [Inline Value Classes](#inline-value-classes) +9. [Sequences for Lazy Evaluation](#sequences-for-lazy-evaluation) +10. [Companion Objects](#companion-objects) +11. [Type Aliases](#type-aliases) +12. [Android View Lifecycle (Interop)](#android-view-lifecycle-interop) +13. [Coroutines routing](#coroutines-routing) + +## Kotlin 2.x and the K2 Compiler + +Target **Kotlin 2.x**. Pinned version lives in `assets/libs.versions.toml.template`. K2 is the default and only supported frontend on Kotlin 2.0+. All patterns below assume K2. + +### Behavioural differences vs K1 + +- Stricter nullability in generic chains. Declare explicit nullability instead of relying on inference. +- More aggressive smart-casts inside lambdas and local functions. Do not add redundant `!!` or re-checks. +- Tighter exhaustiveness checking on `when`. Treat new warnings as errors. +- New diagnostics may surface latent bugs in previously-compiling code. Fix them; do not downgrade. + +### Compose compiler + +Compose compiler ships with Kotlin 2.x. Apply it as a Gradle plugin. Do not depend on `androidx.compose.compiler:compiler` and do not set `kotlinCompilerExtensionVersion`. + +```kotlin +plugins { + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} +``` + +Plugin id: `org.jetbrains.kotlin.plugin.compose`. Its version always matches `kotlin` in the catalog (see `kotlin-compose` in `assets/libs.versions.toml.template`). + +Configure Compose-specific options via the plugin block, never via `freeCompilerArgs`: + +```kotlin +composeCompiler { + reportsDestination = layout.buildDirectory.dir("compose_compiler") + stabilityConfigurationFiles.add( + rootProject.layout.projectDirectory.file("compose_compiler_config.conf") + ) +} +``` + +Stability-report usage: `references/compose-patterns.md`. Convention plugin wiring: `references/gradle-setup.md`. + +### Explicit API mode + +Enable explicit API mode in every non-`:app` module. Required for `core:*` and `feature:*`. Skip for `:app`. + +```kotlin +kotlin { + explicitApi() +} +``` + +Per-compilation form: `explicitApi = ExplicitApiMode.Strict`. + +### Things to remove on Kotlin 2.x + +- `kotlinCompilerExtensionVersion = "…"` inside `composeOptions { }` - ignored, warns. +- `languageVersion = "1.9"` - obsolete. +- `useK2 = true` - obsolete; K2 is default. + +## Delegation (Composition over Inheritance) + +Use delegation (`by`) to compose shared behavior instead of base classes. + +```kotlin +@HiltViewModel +class AuthViewModel @Inject constructor( + crashReporter: CrashReporter, + logger: Logger +) : ViewModel(), + CrashReporter by crashReporter, + Logger by logger { + + fun onLoginClicked() { + log("Login clicked") // Delegated + // ... logic + } +} +``` + +Full delegation patterns and tests: `references/kotlin-delegation.md`. + +## Pragmatic layering & import hygiene + +Keep types and file structure easy to read. This aligns with `references/architecture.md` (layers) and `references/compose-patterns.md` (screens and state). + +### Import hygiene + +Never bury types behind long fully qualified names in business logic. Import at the top of the file; use `import … as …` when two layers expose the same simple name. + +```kotlin +// Bad - package noise hides intent +val unit = com.example.app.data.db.entity.enums.WeightUnit.entries + .find { it.name == rawValue } + +// Good +import com.example.app.data.db.entity.enums.WeightUnit + +val unit = WeightUnit.entries.find { it.name == rawValue } + +// Good - clash between DB and domain enums +import com.example.app.data.db.entity.enums.WeightUnit as DbWeightUnit +import com.example.app.domain.model.WeightUnit + +val dbUnit = DbWeightUnit.entries.find { it.name == rawValue } +val domainUnit = WeightUnit.fromDb(dbUnit) +``` + +**Alias naming:** suffix or prefix with the layer (`Db`, `Api`, `Dto`, `Ui`, `Domain`) so readers see which world a value belongs to. + +### Use cases that only wrap repositories + +A class that only forwards to a repository with no extra policy, validation, or reuse is **noise**: + +```kotlin +// Often unnecessary - call the repository from the ViewModel instead +class GetSettingsUseCase(private val repository: SettingsRepository) { + suspend operator fun invoke() = repository.getSettings() +} +``` + +Keep a **use case** (or domain service) when logic is multi-step, reused across features, policy-heavy, or worth unit-testing on its own - not when it is a one-line pass-through. + +### State updates without extra type layers + +Use **sealed actions**, **`UiState`**, and **one-shot events** (`SharedFlow` or `Channel`) from the ViewModel. Apply state changes with `when (action) { ... }` + `MutableStateFlow.update`. + +Forbidden: a fourth parallel type (`Result`, `PartialState`, a mandatory pure `reduce`) when every action maps 1:1 to a state change. + +Add a dedicated reducer or intermediate "result" type only when many sources (events, async completions, pushes, sockets) must funnel through one centralized transition function. + +### Composable boundaries + +Extract composables when there is **real reuse**, a **stable API**, or a clear visual/behavioral boundary. Do not extract one-line wrappers around `Text` / `Spacer` or "components" used only once - see `references/compose-patterns.md` → "View Composition Rules". + +## Collection APIs + +### Read-only collection APIs + +Expose `List`, `Set`, or `Map` from public APIs and keep mutable collections private. This keeps +mutation localized and makes state transitions explicit. + +```kotlin +class AuthSessionStore { + private val sessions = mutableMapOf() + + fun upsert(session: Session) { + sessions[session.id] = session + } + + fun snapshot(): Map = sessions.toMap() // Return copy, not reference +} +``` + +### Use Explicit State Transitions for Collections + +Model collection changes as pure transformations so updates are predictable and testable. +This also makes it clear what "state machine" step is happening on each event. + +```kotlin +sealed interface SessionEvent { + data class Added(val session: Session) : SessionEvent + data class Removed(val id: String) : SessionEvent +} + +fun reduceSessions( + current: List, + event: SessionEvent +): List = when (event) { + is SessionEvent.Added -> current + event.session + is SessionEvent.Removed -> current.filterNot { it.id == event.id } +} +``` + +### Persistent Collections for State + +When you store lists in Compose or ViewModel state, prefer persistent collections for structural +sharing and stable updates. See: `references/compose-patterns.md` → "Performance Optimization" → "Persistent Collections for Performance". + +## Sealed Classes & Exhaustive When + +Use sealed classes/interfaces for closed type hierarchies with exhaustive `when` expressions. + +```kotlin +// Domain errors +sealed class AuthError(message: String, cause: Throwable? = null) : Exception(message, cause) { + class NetworkError(message: String, cause: Throwable? = null) : AuthError(message, cause) + class InvalidCredentials(message: String) : AuthError(message) + class ServerError(message: String, cause: Throwable? = null) : AuthError(message, cause) +} + +// UI state +@Immutable +sealed interface AuthUiState { + data object Loading : AuthUiState + data class Form(val email: String, val error: String?) : AuthUiState + data class Success(val user: User) : AuthUiState +} + +// Exhaustive when (compiler enforces all cases) +fun handleAuthError(error: AuthError): String = when (error) { + is AuthError.NetworkError -> "No internet connection" + is AuthError.InvalidCredentials -> "Invalid credentials" + is AuthError.ServerError -> "Server error" +} // No else needed; compiler ensures all cases covered + +@Composable +fun AuthScreen(uiState: AuthUiState) { + when (uiState) { + is AuthUiState.Loading -> LoadingIndicator() + is AuthUiState.Form -> LoginForm(uiState) + is AuthUiState.Success -> WelcomeScreen(uiState.user) + } // Exhaustive +} +``` + +See: `references/design-patterns.md` → "Kotlin-Specific Patterns" → "Sealed Classes for Exhaustive State". + +## Generics & Reified Types + +### Generic Result Wrapper + +Use generics for type-safe wrappers and error handling: + +```kotlin +// CORRECT: Generic Result type +sealed class Result { + data class Success(val data: T) : Result() + data class Error(val exception: Exception) : Result() +} + +// Repository with generic Result +interface AuthRepository { + suspend fun login(email: String, password: String): Result + suspend fun register(user: User): Result + suspend fun getProfile(userId: String): Result +} + +// Usage +suspend fun handleLogin(email: String, password: String) { + when (val result = authRepository.login(email, password)) { + is Result.Success -> handleSuccess(result.data) // Type-safe: AuthToken + is Result.Error -> handleError(result.exception) + } +} +``` + +Kotlin stdlib ships `Result`; add a sealed domain result when branches need more structure than `Result` exposes. + +### Reified Type Parameters + +Use `reified` with `inline` functions for runtime type information: + +```kotlin +// CORRECT: Type-safe JSON parsing with reified +inline fun parseJson(json: String): T { + return Json.decodeFromString(json) +} + +// Usage (no need to pass class reference) +val user: User = parseJson(jsonString) +val token: AuthToken = parseJson(tokenJson) + +// CORRECT: Type-safe navigation argument retrieval +inline fun SavedStateHandle.getOrNull(key: String): T? = + get(key) + +// CORRECT: Type-safe Retrofit service creation wrapper +inline fun Retrofit.create(): T { + return create(T::class.java) +} + +// CORRECT: Room 3 DAO with reified type +inline fun Database.dao(): T { + return when (T::class) { + UserDao::class -> userDao() as T + AuthDao::class -> authDao() as T + else -> error("Unknown DAO type") + } +} +``` + +**Rules for Reified:** +- Only works with `inline` functions +- Provides compile-time type safety with runtime access +- Use for: Dependency injection helpers, JSON parsing, type-safe casting + +### Generic Collections with Bounds + +```kotlin +// Generic list processor with upper bound +fun processUsers(users: List): List = + users.map { it.name } + +// Generic repository pattern +interface Repository { + suspend fun getById(id: ID): Result + suspend fun save(entity: T): Result + fun observeAll(): Flow> +} + +class UserRepository @Inject constructor( + private val dao: UserDao +) : Repository { + override suspend fun getById(id: String): Result = runCatching { + dao.getUserById(id) + } + + override suspend fun save(entity: User): Result = runCatching { + dao.insert(entity) + } + + override fun observeAll(): Flow> = dao.observeAll() +} +``` + +## Extension Functions + +Add domain-specific behavior to existing types without inheritance. + +```kotlin +// Domain logic extensions +fun User.isActive(): Boolean = + isVerified && lastActiveAt > Clock.System.now().minus(30.days).toEpochMilliseconds() + +fun User.displayName(): String = + name.ifEmpty { email.substringBefore("@") } + +fun List.filterActive(): List = + filter { it.isActive() } + +// UI formatting extensions +fun Long.toRelativeTime(): String { + val now = Clock.System.now().toEpochMilliseconds() + val diff = (now - this).milliseconds + + return when { + diff < 1.minutes -> "Just now" + diff < 1.hours -> "${diff.inWholeMinutes}m ago" + diff < 1.days -> "${diff.inWholeHours}h ago" + else -> "${diff.inWholeDays}d ago" + } +} + +// Flow extensions +fun Flow.throttle(period: Duration): Flow = flow { + var lastEmitTime = 0L + collect { value -> + val currentTime = Clock.System.now().toEpochMilliseconds() + if (currentTime - lastEmitTime >= period.inWholeMilliseconds) { + lastEmitTime = currentTime + emit(value) + } + } +} + +// Usage +@Composable +fun UserCard(user: User) { + if (user.isActive()) { + Text(user.displayName()) + Text(user.lastActiveAt.toRelativeTime()) + } +} +``` + +**Rules:** +- Keep extensions in the same module as the type, or in `core:common`. +- Use extension functions instead of `*Utils` classes. +- Name them so the call reads naturally: `user.displayName()`, never `UserUtils.getDisplayName(user)`. + +See: `references/design-patterns.md` → "Kotlin-Specific Patterns" → "Extension Functions for Domain Logic". + +## Inline Value Classes + +Use inline value classes for type-safe wrappers with zero runtime overhead. + +```kotlin +// CORRECT: Type-safe IDs +@JvmInline +value class UserId(val value: String) + +@JvmInline +value class AuthToken(val value: String) + +@JvmInline +value class Email(val value: String) + +// CORRECT: Prevents mixing different ID types +interface UserRepository { + suspend fun getUser(id: UserId): Result // Can't pass Email by mistake +} + +interface AuthRepository { + suspend fun validateToken(token: AuthToken): Result +} + +// Usage +val userId = UserId("123") +val email = Email("user@example.com") + +userRepository.getUser(userId) // CORRECT: compiles — `UserId` matches repository API +userRepository.getUser(email) // WRONG: Compile error - type safety! + +// CORRECT: Type-safe domain values +@JvmInline +value class Temperature(val celsius: Double) { + fun toFahrenheit(): Double = celsius * 9.0 / 5.0 + 32.0 +} + +@JvmInline +value class Distance(val meters: Double) { + fun toKilometers(): Double = meters / 1000.0 +} + +fun displayTemperature(temp: Temperature): String = + "${temp.celsius}°C (${temp.toFahrenheit()}°F)" + +displayTemperature(Temperature(25.0)) +``` + +**Use when:** +- Wrapping primitive types for type safety (IDs, tokens, measurements) +- Domain-specific types that need compile-time enforcement +- No runtime overhead (inlined at compile time) + +**Limitations:** +- Can only wrap a single property +- Some reflection limitations +- Must be public (can't be private) + +## Sequences for Lazy Evaluation + +Use `Sequence` for large collections or chained operations to avoid intermediate allocations. + +### Avoid Memory Churn + +Allocating short-lived objects inside hot loops triggers GC pauses and causes jank. Reuse buffers, or hoist the allocation out of the loop. + +```kotlin +// WRONG: Allocates a new String per iteration (10,001 objects) +for (i in 0..10000) { + val text = "Item number: $i" + processText(text) +} + +// CORRECT: Reuse StringBuilder +val builder = StringBuilder() +for (i in 0..10000) { + builder.clear() + builder.append("Item number: ").append(i) + processText(builder.toString()) +} + +// WRONG: Creates new object each time +fun getCurrentDate(): Date { + return Date() // Called 1000 times = 1000 objects +} + +// CORRECT: Reuse if possible +private var cachedDate: Date? = null +fun getCurrentDate(): Date { + return cachedDate ?: Date().also { cachedDate = it } +} +``` + +```kotlin +// WRONG: Eager evaluation - creates intermediate lists +val activeUserNames = users + .filter { it.isActive() } // Creates List + .map { it.name } // Creates another List + .sortedBy { it.lowercase() } // Creates another List + .take(10) // Creates another List + +// CORRECT: Lazy evaluation - single pass +val activeUserNames = users + .asSequence() + .filter { it.isActive() } + .map { it.name } + .sortedBy { it.lowercase() } + .take(10) + .toList() // Materialize only at the end + +// CORRECT: Generate sequences lazily +fun generateUserIds(): Sequence = sequence { + var counter = 0 + while (true) { + yield(UserId("user_${counter++}")) + } +} + +val first100Ids = generateUserIds().take(100).toList() + +// CORRECT: File processing (avoid loading everything into memory) +fun processLargeFile(file: File): List = + file.useLines { lines -> + lines + .filter { it.isNotBlank() } + .map { it.trim() } + .filter { it.startsWith("ERROR") } + .take(100) + .toList() + } +``` + +**Use when:** +- Large collections (1000+ items) +- Multiple chained operations +- Potentially infinite streams +- File/database cursor iteration + +**When NOT to Use:** +- Small collections (<100 items) +- Single operation +- Need random access or size + +## Companion Objects + +### Constants and Factory Methods + +```kotlin +// CORRECT: Constants in companion object +class AuthConfig { + companion object { + val SESSION_TIMEOUT = 30.minutes + const val MAX_LOGIN_ATTEMPTS = 3 + val EMAIL_REGEX = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$") + } +} + +// CORRECT: Factory methods +@Immutable +data class User private constructor( + val id: String, + val email: String, + val name: String +) { + companion object { + fun create(email: String, name: String): Result { + if (!email.matches(EMAIL_REGEX)) { + return Result.failure(ValidationError.InvalidEmail) + } + if (name.isBlank()) { + return Result.failure(ValidationError.InvalidName) + } + return Result.success(User( + id = UUID.randomUUID().toString(), + email = email.lowercase(), + name = name.trim() + )) + } + + private val EMAIL_REGEX = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$") + } +} + +// Usage +val user = User.create("test@example.com", "Test User").getOrThrow() +``` + +**Top-Level vs Companion Object:** + +```kotlin +// CORRECT: Top-level for pure utility functions +fun formatDuration(duration: Duration): String = + "${duration.inWholeSeconds} seconds" + +// CORRECT: Companion object for type-related constants/factories +class Session { + companion object { + val DEFAULT_TIMEOUT = 30.seconds + fun create(userId: String): Session = Session(userId, Clock.System.now().toEpochMilliseconds()) + } +} +``` + +## Type Aliases + +Use type aliases for readability and to simplify complex generic types. + +```kotlin +// CORRECT: Simplify complex types +typealias UserId = String +typealias AuthCallback = (Result) -> Unit +typealias ValidationRules = Map Boolean> + +// CORRECT: Generic callback types +typealias Callback = (Result) -> Unit +typealias Listener = (T) -> Unit + +// Usage in function signatures +class AuthService { + fun login( + email: String, + password: String, + callback: AuthCallback + ) { + // ... + } +} + +// CORRECT: Flow types +typealias AuthStateFlow = StateFlow +typealias UserListFlow = Flow> + +class AuthViewModel { + val authState: AuthStateFlow = _authState.asStateFlow() +} + +// WRONG: Don't use for single-use types +typealias S = String // Too generic +typealias UEVM = UserEditViewModel // Unreadable abbreviation + +// WRONG: Don't hide important type information +typealias IntList = List // Doesn't add value; use List directly +``` + +**Use when:** +- Complex generic types (`Map>>`) +- Commonly used callback signatures +- Domain-specific terminology (`UserId` vs raw `String`) + +**When NOT to Use:** +- Simple types that don't benefit from aliasing +- When it obscures important type information + +## Destructuring + +Destructure data classes and Pairs for cleaner code: + +```kotlin +// CORRECT: Data class destructuring +data class User(val id: String, val name: String, val email: String) + +val user = User("1", "John", "john@example.com") +val (id, name, email) = user + +// CORRECT: Useful in loops +val users = listOf(user1, user2, user3) +for ((id, name, _) in users) { // _ ignores email + println("$id: $name") +} + +// CORRECT: Map entries +val userMap = mapOf("1" to user1, "2" to user2) +for ((userId, user) in userMap) { + println("User $userId: ${user.name}") +} + +// CORRECT: Pairs from functions +fun getMinMax(numbers: List): Pair = + numbers.min() to numbers.max() + +val (min, max) = getMinMax(listOf(1, 5, 3, 9, 2)) + +// CORRECT: Limited destructuring (only first N components) +data class SearchResult(val id: String, val title: String, val description: String, val score: Float) + +val (id, title) = searchResult // Only destructure first 2 +``` + +**Limitations:** +- Only first 5 components supported by default +- Position-based, not name-based +- Can reduce readability if overused + +## Inline Functions & Reified Types + +### Inline Functions + +Use `inline` for higher-order functions to eliminate lambda overhead: + +```kotlin +// CORRECT: Inline higher-order function +inline fun measureTime(block: () -> T): Pair { + val start = Clock.System.now() + val result = block() + val elapsed = Clock.System.now() - start + return result to elapsed +} + +// Usage (no lambda allocation) +val (user, elapsed) = measureTime { + repository.getUser() +} +println("Took ${elapsed.inWholeMilliseconds}ms") + +// CORRECT: Inline for DSL builders +inline fun buildUser(init: UserBuilder.() -> Unit): User { + val builder = UserBuilder() + builder.init() + return builder.build() +} + +val user = buildUser { + name = "John" + email = "john@example.com" + age = 30 +} +``` + +### Reified Type Parameters + +Retain type information at runtime with `reified`: + +```kotlin +// CORRECT: Generic Activity start +inline fun Context.startActivity() { + startActivity(Intent(this, T::class.java)) +} + +// Usage +context.startActivity() // Type-safe! + +// CORRECT: Generic ViewModel retrieval with Hilt +@Composable +inline fun hiltViewModel(): T { + return androidx.hilt.navigation.compose.hiltViewModel() +} + +// CORRECT: Type-safe navigation arguments +inline fun SavedStateHandle.getOrThrow(key: String): T = + get(key) ?: error("Missing required argument: $key") + +@HiltViewModel +class ProfileViewModel @Inject constructor( + savedStateHandle: SavedStateHandle +) : ViewModel() { + private val userId: UserId = savedStateHandle.getOrThrow("userId") +} + +// CORRECT: Generic JSON serialization +inline fun Json.decodeFromString(string: String): T { + return decodeFromString(serializer(), string) +} + +inline fun Json.encodeToString(value: T): String { + return encodeToString(serializer(), value) +} +``` + +**Rules:** +- Must be `inline` to use `reified` +- Don't overuse; adds code size at call sites +- Best for: DSLs, type-safe wrappers, reflection avoidance + +### `noinline` and `crossinline` + +When a function is `inline`, all its lambda parameters are inlined by default. Use `noinline` and `crossinline` to change that behavior for specific lambdas. + +#### `inline` (default) - Inlined at Call Site + +All lambda parameters are inlined. Non-local `return` is allowed. + +```kotlin +// Timing wrapper for repository calls - zero lambda overhead +inline fun Repository.timed(tag: String, block: () -> T): T { + val start = SystemClock.elapsedRealtime() + val result = block() + Log.d("Perf", "$tag took ${SystemClock.elapsedRealtime() - start}ms") + return result +} + +// Usage - block is inlined, no lambda object created +val user = userRepository.timed("fetchUser") { + remoteDataSource.getUser(userId) +} + +// Compose: inline builder for modifier chains +inline fun Modifier.conditionalPadding( + condition: Boolean, + block: Modifier.() -> Modifier +): Modifier = if (condition) block() else this +``` + +#### `noinline` - Opt a Lambda Out of Inlining + +Use when the lambda must be stored, passed to another function, or returned. Inlined lambdas can't be treated as objects. + +```kotlin +// Error handler must be stored in the WorkManager retry callback +inline fun safeApiCall( + crossinline call: suspend () -> T, + noinline onError: (Throwable) -> Unit // stored in retry callback +): Flow> = flow { + try { + emit(Result.success(call())) + } catch (e: Exception) { + emit(Result.failure(e)) + RetryScheduler.schedule(onError) // passing lambda as object + } +} + +// Click listener stored in View - must be noinline +inline fun View.onDebouncedClick( + debounceMs: Long = 300L, + noinline action: (View) -> Unit // stored by setOnClickListener +) { + var lastClickTime = 0L + setOnClickListener { view -> + val now = SystemClock.elapsedRealtime() + if (now - lastClickTime >= debounceMs) { + lastClickTime = now + action(view) + } + } +} +``` + +#### `crossinline` - Forbid Non-Local Returns + +Use when the lambda executes in a different context (another coroutine, thread, or lambda). Prevents the caller from using `return` to exit the outer function. + +```kotlin +// Lambda runs inside launch {} - different coroutine context +inline fun ViewModel.launchWithLoading( + state: MutableStateFlow, + crossinline block: suspend () -> Unit +) { + viewModelScope.launch { + state.value = true + try { + block() + } finally { + state.value = false + } + } +} + +// Usage +fun loadProfile() { + launchWithLoading(_isLoading) { + // return here would try to exit loadProfile() without crossinline + val user = repository.getUser(userId) + _profile.value = user + } +} + +// Lambda runs in Dispatchers.IO context +inline fun runOnIo(crossinline block: () -> T, crossinline onResult: (T) -> Unit) { + CoroutineScope(Dispatchers.IO).launch { + val result = block() + withContext(Dispatchers.Main) { + onResult(result) + } + } +} +``` + +#### Decision Rules + +| Modifier | Use when | Effect | +|----------|-------------|--------| +| (default) | Lambda used directly at call site | Inlined, non-local `return` allowed | +| `noinline` | Lambda stored, passed to another function, or returned | Not inlined, creates object | +| `crossinline` | Lambda runs in different execution context (launch, withContext) | Inlined, but non-local `return` forbidden | + +## Named Arguments + +Use named arguments for clarity, especially with multiple parameters of the same type: + +```kotlin +// WRONG: Hard to read +authRepository.login("user@example.com", "password123") + +// CORRECT: Clear and explicit +authRepository.login( + email = "user@example.com", + password = "password123" +) + +// CORRECT: Essential for boolean parameters +Button( + onClick = { }, + enabled = true, + modifier = Modifier.fillMaxWidth() +) + +// CORRECT: When parameters have default values +fun createUser( + name: String, + email: String, + age: Int = 18, + isVerified: Boolean = false, + profileUrl: String? = null +) { } + +createUser( + name = "John", + email = "john@example.com", + isVerified = true // Skip age, profileUrl +) +``` + +**Use when:** +- Multiple parameters of same type +- Boolean parameters +- Parameters with defaults +- Builder-like function calls + +## Android View Lifecycle (Interop) + +Custom `View` subclasses (Compose `AndroidView`, legacy widgets, Canvas) sometimes register **lifecycle** observers or process listeners. **Add and remove in pairs** so you do not leak the activity or keep callbacks after the view is gone. + +```kotlin +class MyView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, +) : View(context, attrs), DefaultLifecycleObserver { + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + findViewTreeLifecycleOwner()?.lifecycle?.addObserver(this) + } + + override fun onDetachedFromWindow() { + findViewTreeLifecycleOwner()?.lifecycle?.removeObserver(this) + super.onDetachedFromWindow() + } + + override fun onDestroy(owner: LifecycleOwner) { + // Stop sensors, cancel work tied to this view + } +} +``` + +Use `findViewTreeLifecycleOwner()` when the view lives under a `Fragment` or Compose host. For pure composables, use lifecycle-aware APIs from `references/compose-patterns.md` (`LifecycleResumeEffect`, `DisposableEffect`, etc.) instead of manual `View` hooks. + +## Coroutines routing + +### Structured Concurrency + +Always use scoped coroutines; never `GlobalScope`. + +```kotlin +// CORRECT: ViewModel scope +@HiltViewModel +class AuthViewModel @Inject constructor( + private val authRepository: AuthRepository +) : ViewModel() { + + fun login(email: String, password: String) { + viewModelScope.launch { // Canceled when ViewModel cleared + authRepository.login(email, password) + } + } +} + +// CORRECT: Custom scope for repositories +@Singleton +class AuthRepository @Inject constructor( + @IoDispatcher private val dispatcher: CoroutineDispatcher +) { + private val scope = CoroutineScope(dispatcher + SupervisorJob()) + + fun cleanup() { + scope.cancel() + } +} +``` + +### Generic Suspending Functions + +Use generics in suspend functions for reusable async patterns: + +```kotlin +// CORRECT: Generic retry logic +suspend fun retryWithBackoff( + maxAttempts: Int = 3, + initialDelay: Duration = 1.seconds, + maxDelay: Duration = 10.seconds, + factor: Double = 2.0, + block: suspend () -> T +): Result { + var currentDelay = initialDelay + var lastException: Exception? = null + + repeat(maxAttempts) { attempt -> + try { + return Result.success(block()) + } catch (e: Exception) { + lastException = e + if (attempt < maxAttempts - 1) { + delay(currentDelay) + currentDelay = (currentDelay * factor).coerceAtMost(maxDelay) + } + } + } + + return Result.failure(lastException ?: Exception("Unknown error")) +} + +// Usage +suspend fun login(email: String, password: String): Result = + retryWithBackoff { + authApi.login(email, password) + } + +// CORRECT: Generic resource management +suspend fun withTimeoutResult( + timeout: Duration, + block: suspend () -> T +): Result = runCatching { + withTimeout(timeout) { + block() + } +} +``` + +**Full coroutine patterns**: See `references/coroutines-patterns.md` for dispatchers, structured concurrency, cancellation, Flow patterns, testing, and more. + +## Rules Summary + +Required: +- Delegation over inheritance: use `by` for composition. +- Expose read-only collections; keep mutation private. +- Model UI/state with sealed classes for exhaustive `when`. +- Use `Result`, generic repositories, and generic wrappers - never raw `Result`. +- Use `inline fun ` for type-safe runtime ops. +- Add behavior via extension functions, never `*Utils` objects. +- Wrap primitives in `@JvmInline value class` for IDs, tokens, and units. +- Use `Sequence` for chained transformations on large collections. +- Pass named arguments when a call has 3+ parameters or any boolean. +- For custom `View` code, pair `addObserver` with `removeObserver` (see [Android View Lifecycle (Interop)](#android-view-lifecycle-interop)). + +Forbidden: +- `GlobalScope.launch` and `runBlocking` outside `main()` and tests. + +For detailed patterns, see: +- **Delegation**: `references/kotlin-delegation.md` +- **Coroutines**: `references/coroutines-patterns.md` +- **Design Patterns**: `references/design-patterns.md` +- **Architecture**: `references/architecture.md` diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/references/migration.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/migration.md new file mode 100644 index 000000000..fa27fc1d0 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/migration.md @@ -0,0 +1,778 @@ +# Migration Guide + +Consolidated migration paths for modernizing Android codebases. Each section shows the legacy +pattern and its modern replacement. + +## Table of Contents + +1. [XML to Compose](#xml-to-compose) +2. [LiveData to StateFlow](#livedata-to-stateflow) +3. [RxJava to Coroutines](#rxjava-to-coroutines) +4. [Navigation 2.x to Navigation3](#navigation-2x-to-navigation3) +5. [Accompanist to Official APIs](#accompanist-to-official-apis) +6. [Compose API Migrations](#compose-api-migrations) +7. [Material 2 to Material 3](#material-2-to-material-3) +8. [Edge-to-Edge](#edge-to-edge) +9. [Legacy splash to Splash Screen API](#legacy-splash-to-splash-screen-api) +10. [Room 2.x to Room 3](#room-2x-to-room-3) +11. [Android 17 (API 37) Migration](#android-17-api-37-migration) + +## XML to Compose + +### Strategy: Screen-by-Screen + +Migrate one screen at a time. Do not attempt a full rewrite. Required order: + +1. **Leaf screens first** - screens with no child Fragments or complex navigation +2. **Shared components** - extract reusable Composables to `core/ui` as you go +3. **Container screens** - screens that host Fragments or ViewPagers (migrate after children) +4. **Navigation** - migrate to Navigation3 once all screens are Compose + +### Per-Screen Workflow (mandatory) + +Run for **every** XML screen being migrated: + +1. **Capture a baseline screenshot** of the existing XML UI. Reuse an existing screenshot test if present; otherwise add a minimal **UI Automator** or **Espresso** test that opens the screen and saves a screenshot. This is the diff target for steps 2-4. +2. **Migrate only the minimum theming** required for the screen. Do **not** port the whole `styles.xml` / `themes.xml`. Map only the colors, typography, and shapes used by this screen into `MaterialTheme` (see `references/android-theming.md`). Leave the rest of the XML theme untouched. +3. **Add a `@Preview`** for every new composable. A composable without `@Preview` cannot be diff-verified against step 1. +4. **Diff against baseline.** Iterate until layout and styling match (ignore string content). On parity, write a Compose UI test for the new screen, then run the interop and replacement steps. + +Delete the XML layout, drawables, styles, and legacy tests **only after** all references are gone. + +### Compose in XML (Adding Compose to Existing Screens) + +Use `ComposeView` to embed Compose inside an XML layout: + +```kotlin +// In Fragment or Activity +val composeView = findViewById(R.id.compose_container) +composeView.setContent { + AppTheme { + MyNewComposableComponent( + state = viewModel.uiState.collectAsStateWithLifecycle().value, + onAction = viewModel::onAction + ) + } +} +``` + +```xml + + +``` + +### XML in Compose (Using Legacy Views in Compose Screens) + +Use `AndroidView` to embed existing XML views inside Compose: + +```kotlin +@Composable +fun LegacyMapView(modifier: Modifier = Modifier) { + AndroidView( + factory = { context -> + MapView(context).apply { + onCreate(null) + } + }, + update = { mapView -> + mapView.getMapAsync { map -> + // configure map + } + }, + modifier = modifier + ) +} +``` + +Use `AndroidView` only for views that have no Compose equivalent (e.g., `MapView`, `WebView`, +`AdView`). For standard UI elements, always use Compose directly. + +### Migration Checklist + +- Replace `Fragment` + XML layout with a `@Composable` function +- Replace `ViewBinding` / `DataBinding` with Compose state +- Replace `RecyclerView` with `LazyColumn` / `LazyRow` +- Replace `ConstraintLayout` with Compose `Row`, `Column`, `Box` (or `ConstraintLayout` for Compose) +- Replace `styles.xml` theming with `MaterialTheme` (see `references/android-theming.md`) +- Replace XML string resources usage with `stringResource()` in Compose + +### Compose-XML interop (hardening) + +**Theme:** Wrap every `ComposeView.setContent { }` root in the same `MaterialTheme` entry used by fully Compose screens ([android-theming.md](/references/android-theming.md)). Forbidden: rely on legacy XML `ThemeOverlay` colors inside Compose without mapping tokens to `MaterialTheme.colorScheme`. + +**Focus and IME:** When a legacy `EditText` sits beside `ComposeView`, coordinate `FocusRequester` in Compose with `View.clearFocus` / `requestFocus` on the View side so IME and `windowSoftInputMode` stay aligned with [compose-patterns.md](/references/compose-patterns.md) edge-to-edge and IME sections. + +**ViewModel scope:** `ComposeView` inside a `Fragment` uses `hiltViewModel()` on that fragment's graph; avoid activity-scoped ViewModels for nested composables unless navigation explicitly requires it ([architecture.md → ViewModel placement](/references/architecture.md#viewmodel-placement)). + +**Testing:** Hybrid screens may pair Espresso or UIAutomator on View subtrees with `createComposeRule` on isolated Compose mounts; otherwise one instrumented screenshot or journey per [testing.md](/references/testing.md) and the baseline workflow in [XML to Compose](#xml-to-compose). + +**Long-lived `AndroidView`:** Keep only for surfaces without first-class Compose equivalents (`MapView`, `WebView`, `AdView`, vendor SDK views). Forbidden: wrap `TextView` or `RecyclerView` only to postpone Compose migration. + +## LiveData to StateFlow + +### ViewModel Migration + +```kotlin +// OLD: LiveData +class UserViewModel : ViewModel() { + private val _user = MutableLiveData() + val user: LiveData = _user + + fun loadUser() { + viewModelScope.launch { + _user.value = repository.getUser() + } + } +} + +// NEW: StateFlow +class UserViewModel : ViewModel() { + private val _user = MutableStateFlow(null) + val user: StateFlow = _user.asStateFlow() + + fun loadUser() { + viewModelScope.launch { + _user.value = repository.getUser() + } + } +} +``` + +### UI Collection + +```kotlin +// OLD: LiveData observation in Fragment +viewModel.user.observe(viewLifecycleOwner) { user -> + binding.userName.text = user.name +} + +// NEW: StateFlow in Compose +val user by viewModel.user.collectAsStateWithLifecycle() +UserScreen(user = user) +``` + +### Transformations + +```kotlin +// OLD: LiveData transformations +val userName: LiveData = user.map { it.name } +val userDetails: LiveData
= user.switchMap { repository.getDetails(it.id) } + +// NEW: Flow operators +val userName: StateFlow = user.map { it?.name.orEmpty() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), "") + +val userDetails: StateFlow = user + .filterNotNull() + .flatMapLatest { repository.getDetails(it.id) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) +``` + +### Key Differences + +- `LiveData` requires an initial observer to emit; `StateFlow` always has a value (requires initial state) +- `LiveData.observe()` is lifecycle-aware by default; use `collectAsStateWithLifecycle()` for the same behavior with Flow +- `StateFlow` uses `SharingStarted.WhileSubscribed(5_000)` to survive configuration changes + +## RxJava to Coroutines + +### Coexistence (Migration Not Yet Planned) + +When maintaining projects with both RxJava and Coroutines, expose UI state via `StateFlow` +regardless of the underlying implementation: + +```kotlin +@HiltViewModel +class ProductsViewModel @Inject constructor( + private val getProductsUseCase: GetProductsUseCase, + private val disposables: CompositeDisposable +) : ViewModel() { + + private val _uiState = MutableStateFlow(ProductsUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + fun loadProducts() { + getProductsUseCase.execute() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { products -> + _uiState.value = ProductsUiState.Success(products) + }, + { error -> + _uiState.value = ProductsUiState.Error(error.message ?: "Unknown error") + } + ) + .also { disposables.add(it) } + } + + override fun onCleared() { + super.onCleared() + disposables.clear() + } +} +``` + +UI code uses `collectAsStateWithLifecycle()` regardless of whether the ViewModel uses Coroutines +or RxJava: + +```kotlin +@Composable +fun ProductsRoute(viewModel: ProductsViewModel = hiltViewModel()) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + ProductsScreen( + state = uiState, + onRetry = viewModel::loadProducts + ) +} +``` + +### Disposal Management + +**Option 1: CompositeDisposable** - default. Use unless an existing module already wires AutoDispose. + +```kotlin +class ProductsViewModel : ViewModel() { + private val disposables = CompositeDisposable() + + fun loadProducts() { + getProductsUseCase() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(...) + .also { disposables.add(it) } + } + + override fun onCleared() { + super.onCleared() + disposables.clear() + } +} +``` + +**Option 2: AutoDispose (third-party, requires base ViewModel)** + +```kotlin +dependencies { + implementation(libs.autodispose.android) + implementation(libs.autodispose.android.archcomponents) +} + +class ProductsViewModel : ViewModel(), LifecycleScopeProvider by AndroidLifecycleScopeProvider.from(this) { + fun loadProducts() { + getProductsUseCase() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .autoDispose(this) + .subscribe(...) + } +} +``` + +### Paging with RxJava + +Use `paging-rxjava3` alongside `paging-compose`: + +```kotlin +dependencies { + implementation(libs.androidx.paging.runtime) + implementation(libs.androidx.paging.compose) + implementation(libs.androidx.paging.rxjava3) +} + +class ProductsPagingSource( + private val productsApi: ProductsApi +) : RxPagingSource() { + + override fun loadSingle(params: LoadParams): Single> { + val page = params.key ?: 1 + + return productsApi.getProducts(page, params.loadSize) + .map { response -> + LoadResult.Page( + data = response.products, + prevKey = if (page == 1) null else page - 1, + nextKey = if (response.hasMore) page + 1 else null + ) as LoadResult + } + .onErrorReturn { error -> + LoadResult.Error(error) + } + } + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) + } + } +} + +// ViewModel bridges to Flow for Compose +class ProductsViewModel @Inject constructor( + private val productsApi: ProductsApi +) : ViewModel() { + val products: Flow> = Pager( + config = PagingConfig(pageSize = 20), + pagingSourceFactory = { ProductsPagingSource(productsApi) } + ).flow + .cachedIn(viewModelScope) +} +``` + +### Migration Path (When Ready) + +When planning RxJava to Coroutines migration: + +1. Start with data layer (repositories) +2. Then domain layer (use cases) +3. Finally ViewModels +4. UI layer already uses `StateFlow.collectAsStateWithLifecycle()`, so no changes needed + +### Coexistence rules + +- Use `StateFlow` for UI state. Never expose `Observable`/`Single` from a ViewModel. +- Confine RxJava to data/domain layers. Convert to `StateFlow` at the ViewModel boundary. +- Dispose every subscription via `CompositeDisposable.clear()` in `onCleared()`, or AutoDispose. +- Forbidden: mixing RxJava and coroutines inside the same function. +- New code: coroutines + Flow only. RxJava is permitted only inside legacy modules pending migration. + +Reference: [RxJava to Coroutines migration guide](https://developer.android.com/kotlin/coroutines/coroutines-adv#additional-resources). + +## Navigation 2.x to Navigation3 + +### Key Changes + +| Navigation 2.x | Navigation3 | +|-----------------------------|--------------------------------------------------| +| `NavHost` + `NavController` | `NavDisplay` + `NavBackStack` | +| `composable("route")` | `entryProvider` | +| String or type-safe routes | `@Serializable` data class implementing `NavKey` | +| `navController.navigate()` | `backStack.add()` | +| `rememberNavController()` | `rememberNavBackStack(startKey)` | +| `popBackStack()` | `backStack.removeLastOrNull()` | + +### Migration Steps + +1. Update imports from `androidx.navigation.*` to `androidx.navigation3.*` +2. Replace `NavHost` with `NavDisplay` and `rememberNavController()` with `rememberNavBackStack()` +3. Convert route strings/classes to `@Serializable` data classes implementing `NavKey` +4. Replace `composable("route") { }` blocks with `entryProvider { }` entries +5. Replace `navController.navigate(...)` calls with `backStack.add(...)` +6. Use `NavigationSuiteScaffold` for adaptive navigation (it handles switching automatically) +7. Use `NavigableListDetailPaneScaffold` / `NavigableSupportingPaneScaffold` for tablet-optimized layouts + +For complete Navigation3 architecture, state management, deep links, and adaptive patterns, see `references/android-navigation.md`. + +## Accompanist to Official APIs + +All Accompanist libraries listed below are deprecated. Use the official replacements. + +### System UI Controller -> enableEdgeToEdge() + +```kotlin +// Old (remove accompanist-systemuicontroller dependency) +val systemUiController = rememberSystemUiController() +systemUiController.setSystemBarsColor(color = Color.Transparent) + +// New: call in Activity.onCreate() before setContent +enableEdgeToEdge() +``` + +### Pager -> Foundation HorizontalPager/VerticalPager + +```kotlin +// Old (remove accompanist-pager dependency) +val pagerState = rememberPagerState() +HorizontalPager(count = items.size, state = pagerState) { page -> } + +// New: Foundation pager (page count is a lambda) +val pagerState = rememberPagerState(pageCount = { items.size }) +HorizontalPager(state = pagerState) { page -> } +``` + +### SwipeRefresh -> PullToRefreshBox + +```kotlin +// Old (remove accompanist-swiperefresh dependency) +SwipeRefresh( + state = rememberSwipeRefreshState(isRefreshing), + onRefresh = { load() } +) { content() } + +// New: Material3 PullToRefreshBox +PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = { load() } +) { content() } +``` + +### FlowLayout -> Foundation FlowRow/FlowColumn + +```kotlin +// Old (remove accompanist-flowlayout dependency) +FlowRow(mainAxisSize = SizeMode.Expand) { + items.forEach { Chip(it) } +} + +// New: Foundation FlowRow +FlowRow(modifier = Modifier.fillMaxWidth()) { + items.forEach { Chip(it) } +} +``` + +### Permissions -> activity-compose + +```kotlin +// Old (remove accompanist-permissions dependency) +// import com.google.accompanist.permissions.rememberPermissionState + +// New: same API, different dependency (androidx.activity:activity-compose) +val permissionState = rememberPermissionState(Manifest.permission.CAMERA) { granted -> + // handle result +} +``` + +## Compose API Migrations + +### collectAsState -> collectAsStateWithLifecycle + +```kotlin +// Old: collects even when app is backgrounded (wastes resources) +val state by viewModel.uiState.collectAsState() + +// New: stops collecting when lifecycle is below STARTED +val state by viewModel.uiState.collectAsStateWithLifecycle() +``` + +Requires `androidx.lifecycle:lifecycle-runtime-compose`. + +### mutableStateOf(0) -> mutableIntStateOf(0) + +Primitive specializations avoid boxing overhead: + +```kotlin +// Old +var count by remember { mutableStateOf(0) } +var progress by remember { mutableStateOf(0.5f) } +var timestamp by remember { mutableStateOf(0L) } + +// New +var count by remember { mutableIntStateOf(0) } +var progress by remember { mutableFloatStateOf(0.5f) } +var timestamp by remember { mutableLongStateOf(0L) } +``` + +Available: `mutableIntStateOf`, `mutableLongStateOf`, `mutableFloatStateOf`, `mutableDoubleStateOf`. + +### animateItemPlacement -> animateItem + +```kotlin +// Old +LazyColumn { + items(items, key = { it.id }) { item -> + ItemRow(modifier = Modifier.animateItemPlacement()) + } +} + +// New: handles insert, remove, and reorder animations +LazyColumn { + items(items, key = { it.id }) { item -> + ItemRow(modifier = Modifier.animateItem()) + } +} +``` + +### Modifier.composed -> Modifier.Node + +```kotlin +// Old (deprecated - creates composition scope overhead) +fun Modifier.myModifier(value: Int) = composed { + val state = remember { mutableStateOf(value) } + this.background(if (state.value > 0) Color.Blue else Color.Gray) +} + +// New: Modifier.Node API (no composition scope) +// See references/compose-patterns.md → Modifiers → Custom Modifiers with Modifier.Node +``` + +### Modifier.onFirstVisible -> Modifier.onVisibilityChanged + +Deprecated in Compose 1.11 (April '26). Migrate to [`Modifier.onVisibilityChanged`](https://developer.android.com/reference/kotlin/androidx/compose/ui/layout/package-summary#\(androidx.compose.ui.Modifier\).onVisibilityChanged\(kotlin.Long,kotlin.Float,androidx.compose.ui.layout.LayoutBoundsHolder,kotlin.Function1\)) and track first-visible state manually when needed - `onFirstVisible` re-fires on every scroll pass through a lazy layout. + +```kotlin +// Old (deprecated) +Modifier.onFirstVisible { logImpression(item.id) } + +// New +var alreadyLogged by remember(item.id) { mutableStateOf(false) } +Modifier.onVisibilityChanged { event -> + if (!alreadyLogged && event.visibleFraction > 0f) { + logImpression(item.id) + alreadyLogged = true + } +} +``` + +### String Routes -> Type-Safe Routes -> Navigation3 + +```kotlin +// Old: string-based navigation (pre Navigation 2.8) +navController.navigate("details/$itemId") + +// Migration step: type-safe routes (Navigation 2.8+) +@Serializable data class Details(val itemId: Int) +navController.navigate(Details(itemId = 42)) + +// Current: Navigation3 (see references/android-navigation.md) +@Serializable data class ProductDetail(val productId: String) : NavKey +backStack.add(ProductDetail(productId = "42")) +``` + +### @ExperimentalMaterial3Api Graduations + +These APIs are stable - remove `@OptIn` annotations: + +- `DatePicker` / `DateRangePicker` +- `TimePicker` +- `ExposedDropdownMenuBox` +- `SearchBar` / `DockedSearchBar` +- `ModalBottomSheet` +- `TopAppBar` / `MediumTopAppBar` / `LargeTopAppBar` + +```kotlin +// Old +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MyScreen() { + DatePicker(state = rememberDatePickerState()) +} + +// New: no opt-in needed +@Composable +fun MyScreen() { + DatePicker(state = rememberDatePickerState()) +} +``` + +### Scaffold innerPadding (Mandatory) + +Since Compose 1.6, `Scaffold` requires using `innerPadding`. Ignoring it causes content overlap +with system bars. + +```kotlin +// Bad: ignoring innerPadding (does not compile on Compose 1.6+) +Scaffold(topBar = { TopAppBar { } }) { + LazyColumn { } +} + +// Required: apply innerPadding +Scaffold(topBar = { TopAppBar { } }) { innerPadding -> + LazyColumn(modifier = Modifier.padding(innerPadding)) { } +} +``` + +## Material 2 to Material 3 + +Key changes when migrating from `androidx.compose.material` to `androidx.compose.material3`: + +| Material 2 | Material 3 | +|-------------------------------------|--------------------------------------| +| `MaterialTheme.colors` | `MaterialTheme.colorScheme` | +| `Surface(color = ...)` | `Surface(color = ...)` (same API) | +| `TextField` | `TextField` (same API, new defaults) | +| `BottomNavigation` | `NavigationBar` | +| `BottomNavigationItem` | `NavigationBarItem` | +| `TopAppBar` | `TopAppBar` (different parameters) | +| `Scaffold` (no padding requirement) | `Scaffold` (must use `innerPadding`) | + +Never mix Material 2 and Material 3 imports in the same module. + +For theming setup, see `references/android-theming.md`. + +## Edge-to-Edge + +Edge-to-edge is the default on Android 15+ and mandatory on API 36. + +```kotlin +// Old: manual system bar padding +Surface(modifier = Modifier.systemBarsPadding()) { } + +// New: enableEdgeToEdge() + Scaffold handles it +enableEdgeToEdge() // in Activity.onCreate() +Scaffold { innerPadding -> + Content(modifier = Modifier.padding(innerPadding)) +} +``` + +For full edge-to-edge setup including `WindowInsets` handling, see `references/compose-patterns.md` → "Edge-to-Edge (Mandatory on API 36)". + +## Legacy splash to Splash Screen API + +Required: Migrate off `android:windowBackground`-only splash themes and off dedicated splash `Activity` stacks before relying on Android 12+ launch behavior. Read [Splash screen](https://developer.android.com/develop/ui/views/launch/splash-screen) and [Migrate to the Splash Screen API](https://developer.android.com/develop/ui/views/launch/splash-screen/migrate) for current attributes and activity patterns. + +On API 31+, the system always draws a splash on cold and warm start. A legacy drawable-only launcher theme may be replaced by the default system treatment; a separate `SplashActivity` yields **system splash then your activity** (double splash). + +Required: Add `androidx.core:core-splashscreen` (version catalog: `assets/libs.versions.toml.template`, wiring: `references/gradle-setup.md`). Use the compat library so the same themed splash applies across API levels; platform-only `SplashScreen` without compat leaves pre-12 behavior unchanged. + +Required routing (launcher activity): + +- Manifest: set `android:theme` on the **LAUNCHER** activity to a style whose parent is `Theme.SplashScreen` (or `Theme.SplashScreen.IconBackground` when a circular plate behind the icon is required). +- Theme: set `windowSplashScreenAnimatedIcon`, `windowSplashScreenBackground`, and `postSplashScreenTheme` to the normal app theme per the current attribute list on [Splash screen](https://developer.android.com/develop/ui/views/launch/splash-screen). +- Activity `onCreate`: call `installSplashScreen()` **before** `super.onCreate(savedInstanceState)`. + +Use `Theme.SplashScreen.IconBackground` when the foreground artwork is transparent and must sit on a solid circular icon background. + +**Routing-only activity** (deep link / auth gate): keep a thin activity if routing demands it; hide its content while the system splash stays up, then `startActivity` the real target and `finish()`. + +```kotlin +override fun onCreate(savedInstanceState: Bundle?) { + val splashScreen = installSplashScreen() + super.onCreate(savedInstanceState) + splashScreen.setKeepOnScreenCondition { true } + startActivity(Intent(this, MainActivity::class.java)) + finish() +} +``` + +**Branding-only second activity:** Prefer a single splash via theme attributes (`windowSplashScreenBrandingImage` where supported). If a second screen remains for branding, use `setOnExitAnimationListener` for a controlled handoff per [Splash screen](https://developer.android.com/develop/ui/views/launch/splash-screen). Show dialogs only on the destination activity **after** the system splash is gone. + +**Indeterminate startup:** Forbidden: hold the splash for open-ended network work. Dismiss when local readiness is known; use in-app placeholders or skeleton UI for long or unknown-duration loads ([Migrate](https://developer.android.com/develop/ui/views/launch/splash-screen/migrate)). + +**Forbidden:** + +- `Thread.sleep()`, `Handler.postDelayed()`, or coroutine `delay()` used only to stretch splash time. +- Heavy work, I/O, network, allocations, or `runBlocking` inside `setKeepOnScreenCondition { }`. Multiple cheap flag reads are allowed; blocking or unbounded work is not. + +**Launcher vs splash asset:** Use the same drawable as the launcher adaptive foreground **when** it fits the official splash icon mask without clipping. **Use when:** the launcher asset clips or fails the mask - supply a dedicated splash drawable per [Splash screen](https://developer.android.com/develop/ui/views/launch/splash-screen). + +Theme shape (replace names and colors with project resources): + +```xml + +``` + +Compose apps call `setContent { }` on `ComponentActivity`; View-only apps call `setContentView(...)`. Theme, manifest, and `installSplashScreen()` stay the same. Full checklist and performance rules: `references/android-performance.md` → **App Startup & Initialization** → **Splash Screen**. + +## Room 2.x to Room 3 + +Jetpack **Room 2.x** (`androidx.room`) and **Room 3** (`androidx.room3`) use different Maven coordinates and runtime APIs. The target is **Room 3** on Android with **KSP**, a **`SQLiteDriver`**, and **coroutine-first DAOs** (`suspend`, **`Flow`**). Official background: [Room 3 release notes](https://developer.android.com/jetpack/androidx/releases/room3), [Room 3 announcement](https://android-developers.googleblog.com/2026/03/room-30-modernizing-room.html), and [Save data with Room](https://developer.android.com/training/data-storage/room). + +### Gradle and artifacts + +| Room 2.x | Room 3 | +|---------------------------------------------------------------------|----------------------------------------------------------------------------------------------------| +| `androidx.room:room-runtime`, `room-compiler`, `room-gradle-plugin` | `androidx.room3:room3-runtime`, `room3-compiler`, `room3-gradle-plugin` | +| Plugin id `androidx.room` + `room { schemaDirectory(...) }` | Plugin id `androidx.room3` + `room3 { schemaDirectory(...) }` | +| Optional `room-ktx` | No separate KTX artifact for the same role; use **`Flow` / `suspend`** on DAOs | +| KSP `ksp("androidx.room:room-compiler")` | KSP **`ksp("androidx.room3:room3-compiler")`** - Room 3 is **KSP-only** (no kapt/Java AP for Room) | + +Add **`androidx.sqlite:sqlite-bundled`** and call **`.setDriver(BundledSQLiteDriver())`** on `Room.databaseBuilder` / `Room.inMemoryDatabaseBuilder`. See `assets/libs.versions.toml.template` and the `app.android.room` convention plugin. + +**Paging:** If a DAO returns **`PagingSource`**, add **`androidx.room3:room3-paging`** and **`@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)`** on the DAO or `@Database` ([release notes](https://developer.android.com/jetpack/androidx/releases/room3)). + +### Packages and generated code + +- Replace imports **`androidx.room.*`** with **`androidx.room3.*`** (`RoomDatabase`, `Room`, `@Database`, `@Entity`, `@Dao`, `@Query`, `Migration`, etc.). +- Regenerate with KSP after changing coordinates; update **R8** rules to **`androidx.room3.RoomDatabase`** / **`@androidx.room3.Entity`** (`assets/proguard-rules.pro.template`). + +### SupportSQLite and `SQLiteDriver` + +Room 3 is backed by the **`androidx.sqlite`** driver APIs. **`SupportSQLiteDatabase`**, **`SupportSQLiteQuery`**, and **`openHelper` / `openHelperFactory`** are gone unless you use the compatibility **`androidx.room3:room3-sqlite-wrapper`** for specific legacy call sites ([release notes](https://developer.android.com/jetpack/androidx/releases/room3)). + +- **Callbacks and migrations** that took **`SupportSQLiteDatabase`** should use **`SQLiteConnection`** (or the types your Room 3 version documents for `Migration` / `RoomDatabase.Callback` / `AutoMigrationSpec`). +- **Direct SQL / `Cursor`**: prefer **`RoomDatabase.useReaderConnection`** / **`useWriterConnection`** and prepared statements over raw Android `Cursor` ([release notes](https://developer.android.com/jetpack/androidx/releases/room3)). +- **Transactions:** e.g. **`runInTransaction`**-style usage moves to **`withWriteTransaction`** (see [Room 3 release notes](https://developer.android.com/jetpack/androidx/releases/room3)). +- **Builder options** (e.g. pre-packaged database, query callback, multi-instance invalidation): verify each against the current [Room 3](https://developer.android.com/jetpack/androidx/releases/room3) and [Room training guide](https://developer.android.com/training/data-storage/room) for your AGP/targets; some APIs differ or moved with the driver model. + +Step-by-step **SupportSQLite → driver** guidance: [Migrate from SupportSQLite](https://developer.android.com/kotlin/multiplatform/room#migrate) (Android-relevant parts apply even in Android-only apps). + +### Invalidation and tests + +- **`InvalidationTracker.Observer`** / **`addObserver`** are removed; use **`InvalidationTracker.createFlow`** ([release notes](https://developer.android.com/jetpack/androidx/releases/room3)). +- **Instrumented tests:** `androidx.room3:room3-testing`, **`MigrationTestHelper`** with a **`SQLiteDriver`**, **`SQLiteConnection`**, and **suspend** APIs - see [Test migrations](https://developer.android.com/training/data-storage/room/migrating-db-versions#test) and [`MigrationTestHelper`](https://developer.android.com/reference/kotlin/androidx/room3/testing/MigrationTestHelper). In-repo examples: `references/testing.md`. + +### Room 2.x lifecycle (context only) + +Room 2.x remains in **maintenance** (bugfixes / dependency updates) while Room 3 is the active line ([blog](https://android-developers.googleblog.com/2026/03/room-30-modernizing-room.html)). Plan upgrades on a branch; align **Kotlin**, **KSP**, and **sqlite** versions with [Room 3 releases](https://developer.android.com/jetpack/androidx/releases/room3). + +## Android 17 (API 37) Migration + +Install **Android SDK Platform 37** in the SDK Manager (distinct from platform-tools). `compileSdk = 37` without that platform package fails sync. + +Set `compileSdk` / `targetSdk` to 37 in the version catalog. Pin `agp`, Gradle wrapper, `kotlin`, and `ksp` as **independently verified** pairs per [gradle-setup.md](gradle-setup.md#agp-version-pin-resolve-before-merge), [gradle-setup.md → Example tested stack](gradle-setup.md#example-tested-stack-re-verify-after-every-bump), and [dependencies.md](dependencies.md#kotlin--compose-compiler-compatibility). Gradle wrapper 9.5.x does **not** imply AGP 9.5.x; HTTP 404 on `com.android.tools.build:gradle:` means that AGP coordinate is not published yet on `google()` - pick a lower published AGP that still supports API 37. Catalog `kotlin` and `ksp` need a supported combination; KSP patch numbers may differ from Kotlin patch numbers - resolve from Maven Central / KSP release notes, then run `./gradlew help`. Leave AGP built-in Kotlin enabled; do not flip `android.builtInKotlin=false` mid-migration without a full plugin plan ([gradle-setup.md → Built-in Kotlin (AGP 9)](gradle-setup.md#built-in-kotlin-agp-9)). If `compile*JavaWithJavac` fails with `MissingValueException`, isolate JaCoCo combined coverage wiring before bumping Kotlin ([android-code-coverage.md](android-code-coverage.md)). Align the Compose BOM per [dependencies.md](dependencies.md). + +Apply each topic below in order; authoritative rules live in the linked references. + +### 16 KB memory page size (Play and native code) + +Google Play blocks new apps and updates to existing apps that target Android 15+ on 64-bit when packaged native libraries fail 16 KB page-size compatibility. Read [Support 16 KB page sizes](https://developer.android.com/guide/practices/page-sizes) and [Prepare Play apps for 16 KB devices](https://android-developers.googleblog.com/2025/05/prepare-play-apps-for-devices-with-16kb-page-size.html) for deadlines, ELF rules, NDK/AGP defaults, packaging, and emulator images. + +Required: treat every `*.so` under `arm64-v8a` and `x86_64` (CMake/ndk-build outputs, `jniLibs/`, prebuilts, game engines, SQL/ML/media SDKs). + +Use when: a release APK or AAB contains `lib/` - run alignment verification on that artifact before upload. + +Forbidden: skipping the audit on Kotlin-only app modules; transitive AARs still inject native libs. + +Verification: + +- Run APK Analyzer on the release build; inspect each `.so` Alignment column per the page-size guide. +- Run AOSP `check_elf_alignment.sh` on the release APK, or inspect extracted `lib/**/*.so` with `llvm-readelf` / `readelf` exactly as [ELF alignment checks](https://developer.android.com/guide/practices/page-sizes#elf-alignment) describe. +- Boot a 16 KB emulator image and execute the app’s critical paths per [Test in a 16 KB environment](https://developer.android.com/guide/practices/page-sizes#test). + +Build and supply chain: + +- Bump NDK and AGP only through pairs already validated in [gradle-setup.md](gradle-setup.md) and [dependencies.md](dependencies.md); follow the page-size guide **Build** / **Compile** sections for linker flags and defaults on the active NDK line. +- Prebuilt `.so` files from vendors: upgrade the SDK, obtain a 16 KB-aligned artifact, or drop the dependency - relinking inside the app does not fix an opaque third-party binary. + +### Launcher `Activity` soft input (IME baseline) + +Set `android:windowSoftInputMode="adjustResize"` on the launcher `Activity` that hosts Compose even when no `TextField` exists yet; first text input on target SDK 37 otherwise hits IME inset footguns. Full rules: [compose-patterns.md → IME (soft keyboard) insets](compose-patterns.md#ime-soft-keyboard-insets). + +### Cleartext traffic + +At target SDK 37, cleartext defaults off unless a Network Security Config or manifest flag overrides it. Replace blanket `usesCleartextTraffic="true"` with domain-scoped NSC entries for dev and staging hosts. Full directives: [android-security.md → Network Security Configuration](android-security.md#network-security-configuration). + +### Loopback (127.0.0.1) + +Cross-process loopback sockets require the API 37 permission and pairing rules in [android-security.md → Loopback access (API 37)](android-security.md#loopback-access-api-37). + +### Certificate Transparency + +Default CT enforcement and per-domain opt-out live in [android-security.md → Certificate Transparency (API 37)](android-security.md#certificate-transparency-api-37). + +### Background media playback + +Route background audio and video through Media3 `MediaSessionService`, `mediaPlayback` foreground service type, and a `MediaSession` around a `Player`. Standalone `MediaPlayer`, `AudioTrack`, or raw `ExoPlayer` without a session breaks at target 37. Manifest and service skeleton: [android-media.md → Background media playback hardening (API 37)](android-media.md#background-media-playback-hardening-api-37). + +### Large-screen orientation and resizability + +`screenOrientation`, `resizableActivity="false"`, and aspect-ratio caps are ignored on `sw600dp+` displays at API 36+; API 37 keeps that rule and expects the app window to fill the display on those devices. Build `WindowSizeClass`-driven UIs instead of manifest locks. Games (`android:appCategory="game"`) follow platform carve-outs; confirm eligibility in [Android 17 migration](https://developer.android.com/about/versions/17/migration). In-repo layout rules: [compose-patterns.md → Adaptive Layouts (Mandatory on API 36+ for Large Screens)](compose-patterns.md#adaptive-layouts-mandatory-on-api-36-for-large-screens). + +### IME after rotation + +Target SDK 37 does not restore IME visibility across configuration changes by default. Wire `android:windowSoftInputMode` and runtime `WindowInsetsControllerCompat` per [compose-patterns.md → IME (soft keyboard) insets](compose-patterns.md#ime-soft-keyboard-insets). + +### JVM unit tests without Robolectric + +Pure ViewModel, coroutine, and JVM tests under `src/test/` that do not use `@RunWith(RobolectricTestRunner::class)` skip Robolectric pinning entirely. + +### Robolectric JVM tests + +Robolectric 4.16.x shadows top out at SDK 36 until a newer release adds SDK 37; pin `@Config(sdk = [Build.VERSION_CODES.BAKLAVA])` and run JVM tests on JDK 21 when using that shadow. Full checklist: [testing.md → Robolectric and SDK 37 (Android 17)](testing.md#robolectric-and-sdk-37-android-17). + +### Espresso instrumented tests + +Keep **androidx.test.espresso:espresso-core** on the catalog version (`3.7.0`); sync Gradle after the catalog bump. No separate Espresso migration path for API 37. + +### Explicit URI grants on shares + +Attach `FLAG_GRANT_READ_URI_PERMISSION` or `FLAG_GRANT_WRITE_URI_PERMISSION` explicitly when putting `content` URIs on `ACTION_SEND` (and similar) intents. Rules: [android-security.md → Forward-compatible URI grants (Android 18 prep)](android-security.md#forward-compatible-uri-grants-android-18-prep). diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/references/modularization.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/modularization.md new file mode 100644 index 000000000..e0b171cce --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/modularization.md @@ -0,0 +1,401 @@ +# Modularization + +Multi-module Android setup with Navigation 3, Jetpack Compose, and strict dependency direction. All Kotlin code must align with `references/kotlin-patterns.md`. + +Multi-module is **required** for any project beyond a single throwaway sample. Use it when: + +- The project has 2+ user-facing features. +- Build times exceed 30s clean. +- More than one engineer ships changes in parallel. +- `core/domain` will be reused across product surfaces (app, Wear, TV). + +## Table of Contents +1. [Module Types](#module-types) +2. [Module Structure](#module-structure) +3. [Dependency Rules](#dependency-rules) +4. [Creating Modules](#creating-modules) +5. [Navigation Coordination](#navigation-coordination) +6. [Build Configuration](#build-configuration) + +## Build Configuration + +Convention plugin definitions and examples live in: +- `assets/convention/` - All plugin source files (.kt) +- `references/gradle-setup.md` - Detailed build configuration patterns +- `assets/convention/QUICK_REFERENCE.md` - Setup instructions and examples + +Copy plugin files from `assets/convention/` to `build-logic/convention/src/main/kotlin/` in your project. + +## Module Types + +### App Module (`app/`) +Entry point that brings everything together with Navigation3 adaptive navigation. + +**Contains**: +- `MainActivity` with `NavigationSuiteScaffold` +- `AppNavigation` composable with `NavigationSuiteScaffold` +- `NavigationState` and `Navigator` for state management +- `entryProvider` with all feature destinations +- `NavDisplay` to render current destination +- `Navigator` implementations for feature coordination +- Hilt DI setup and component + +**Dependencies**: All feature modules, all core modules + +### Feature Modules (`feature/*`) +Self-contained features with clear boundaries and no inter-feature dependencies. +See [Feature Module Structure](#feature-module-structure) for the full directory layout. + +### Core Modules (`core/`) +Shared library code used across features with strict dependency direction. + +| Module | Purpose | Dependencies | Key Classes | +|------------------|-------------------------------------------------|-----------------------------------------------------|---------------------------------------------------------------------| +| `core:domain` | Domain models, use cases, repository interfaces | None (pure Kotlin) | `AuthToken`, `User`, `LoginUseCase`, `AuthRepository` interface | +| `core:data` | Repository implementations, data coordination | `core:domain` | `AuthRepositoryImpl`, `AuthRemoteDataSource`, `AuthLocalDataSource` | +| `core:database` | Room 3 database, DAOs, entities | `core:model` (if separate), otherwise `core:domain` | `AuthDatabase`, `AuthTokenDao`, `UserEntity` | +| `core:network` | Retrofit API, network models | `core:model` (if separate), otherwise `core:domain` | `AuthApi`, `NetworkAuthResponse` | +| `core:datastore` | Proto DataStore preferences | None | `AuthPreferencesDataSource` | +| `core:common` | Shared utilities, extensions | None | `AppDispatchers`, `ResultExtensions` | +| `core:ui` | Reusable UI components, themes, base ViewModels | `core:domain` when composables read shared models | `AuthForm`, `AuthTheme`, `BaseViewModel` | +| `core:testing` | Test utilities, test doubles | Depends on module being tested | `TestDispatcherRule`, `FakeAuthRepository` | + +## Module Structure + +### Complete Project Structure + +``` +app/ # App module - navigation, DI setup, app entry point +feature/ + ├── feature-auth/ # Authentication feature + ├── feature-onboarding/ # Signup and onboarding flow + ├── feature-profile/ # User profile feature + ├── feature-settings/ # App settings feature + └── feature-/ # Additional features... +core/ + ├── domain/ # Pure Kotlin: Use Cases, Repository interfaces, Domain models + ├── data/ # Data layer: Repository impl, DataSources, Data models + ├── ui/ # Shared UI components, themes, base ViewModels + ├── network/ # Retrofit, API models, network utilities + ├── database/ # Room 3 DAOs, entities, migrations + ├── datastore/ # Preferences storage + ├── common/ # Shared utilities, extensions + └── testing/ # Test utilities, test doubles +build-logic/ # Convention plugins for consistent builds + ├── convention/ + │ ├── src/main/kotlin/ + │ │ ├── AndroidApplicationConventionPlugin.kt + │ │ ├── AndroidApplicationComposeConventionPlugin.kt + │ │ ├── AndroidApplicationBaselineProfileConventionPlugin.kt + │ │ ├── AndroidApplicationJacocoConventionPlugin.kt + │ │ ├── AndroidLibraryConventionPlugin.kt + │ │ ├── AndroidLibraryComposeConventionPlugin.kt + │ │ ├── AndroidLibraryJacocoConventionPlugin.kt + │ │ ├── AndroidFeatureConventionPlugin.kt + │ │ ├── AndroidTestConventionPlugin.kt + │ │ ├── AndroidRoomConventionPlugin.kt + │ │ ├── AndroidLintConventionPlugin.kt + │ │ ├── HiltConventionPlugin.kt + │ │ ├── DetektConventionPlugin.kt + │ │ ├── SpotlessConventionPlugin.kt + │ │ ├── JvmLibraryConventionPlugin.kt + │ │ ├── KotlinSerializationConventionPlugin.kt + │ │ ├── FirebaseConventionPlugin.kt + │ │ ├── SentryConventionPlugin.kt + │ │ └── config/ + │ │ ├── KotlinAndroid.kt + │ │ ├── AndroidCompose.kt + │ │ ├── ProjectExtensions.kt + │ │ ├── GradleManagedDevices.kt + │ │ ├── AndroidInstrumentationTest.kt + │ │ ├── PrintApksTask.kt + │ │ └── Jacoco.kt + │ └── build.gradle.kts +``` + +### Feature Module Structure + +``` +feature-auth/ +├── build.gradle.kts +├── src/main/ +│ ├── kotlin/com/example/feature/auth/ +│ │ ├── presentation/ # Presentation Layer +│ │ │ ├── AuthScreen.kt # Main composable +│ │ │ ├── AuthRoute.kt # Feature route composable +│ │ │ ├── viewmodel/ +│ │ │ │ ├── AuthViewModel.kt # State holder +│ │ │ │ ├── AuthUiState.kt # UI state models +│ │ │ │ └── AuthActions.kt # User actions +│ │ │ └── components/ # Feature-specific UI components +│ │ │ ├── AuthFormCard.kt +│ │ │ └── AuthHeader.kt +│ │ ├── navigation/ # Navigation Layer +│ │ │ ├── AuthDestination.kt # Feature routes (sealed class) +│ │ │ ├── AuthNavigator.kt # Navigation interface +│ │ │ └── AuthGraph.kt # NavGraphBuilder extension +│ │ └── di/ # Feature-specific DI +│ │ └── AuthModule.kt # Hilt module +│ └── res/ # Feature resources +│ ├── drawable/ +│ └── values/ +└── src/test/ # Feature tests + └── kotlin/com/example/feature/auth/ + ├── presentation/viewmodel/ + │ └── AuthViewModelTest.kt + └── navigation/ + └── AuthDestinationTest.kt +``` + +### Core Module Structure + +``` +core/domain/ +├── build.gradle.kts +├── src/main/kotlin/com/example/core/domain/ +│ ├── model/ # Domain models +│ │ ├── User.kt +│ │ ├── AuthToken.kt +│ │ └── AuthState.kt +│ ├── repository/ # Repository interfaces +│ │ └── AuthRepository.kt +│ ├── usecase/ # Use cases +│ │ ├── LoginUseCase.kt +│ │ ├── RegisterUseCase.kt +│ │ ├── ResetPasswordUseCase.kt +│ │ └── ObserveAuthStateUseCase.kt +│ └── di/ # Domain DI (if needed) +│ └── DomainModule.kt +└── src/test/kotlin/com/example/core/domain/ + ├── model/ + └── usecase/ +``` + +## Dependency Rules + +### Allowed Dependencies + +``` +feature/* → core/domain → core/data + ↓ ↓ +core/ui (when shared UI exists) (no circular dependencies) + +app → all feature modules (for navigation coordination) +app → all core modules (for DI setup) + +NO feature-to-feature dependencies allowed +``` + +### Strict Rules: +1. **Feature modules can only depend on Core modules** +2. **Feature modules cannot depend on other feature modules** +3. **Core/Domain has no Android dependencies** (pure Kotlin) +4. **Core/Data depends on Core/Domain** (implements interfaces) +5. **Core/UI** ships once multiple features share composables, themes, or base `ViewModel` chrome; presentation-only features can defer it until reuse appears +6. **App module depends on all features** for navigation coordination +7. **No circular dependencies** between any modules + +### Visual Dependency Graph + +```mermaid +graph TB + subgraph "App Module" + App[app
Navigation & DI] + end + + subgraph "Feature Modules" + Auth[feature-auth] + Onboarding[feature-onboarding] + Profile[feature-profile] + Settings[feature-settings] + end + + subgraph "Core Modules" + UI[core/ui] + Domain[core/domain] + Data[core/data] + Network[core/network] + Database[core/database] + Common[core/common] + end + + App --> Auth + App --> Onboarding + App --> Profile + App --> Settings + + App --> UI + App --> Domain + App --> Data + App --> Network + App --> Database + App --> Common + + Auth -.-> Domain + Auth -.-> UI + + Onboarding -.-> Domain + Onboarding -.-> UI + + Profile -.-> Domain + Profile -.-> UI + + Settings -.-> Domain + Settings -.-> UI + + Data --> Domain + Data --> Network + Data --> Database + + Network --> Domain + Database --> Domain + + UI -.-> Domain + + style Auth fill:#e1f5fe + style Onboarding fill:#e1f5fe + style Profile fill:#e1f5fe + style Settings fill:#e1f5fe + style UI fill:#f3e5f5 + style Domain fill:#e8f5e8 + style Data fill:#fff3e0 + style Network fill:#fff3e0 + style Database fill:#fff3e0 + style Common fill:#f5f5f5 +``` + +## Creating Modules + +### 1. Create Feature Module + +**Step 1: Create directory structure** +``` +mkdir -p feature-auth/src/main/kotlin/com/example/feature/auth/{presentation/{viewmodel,components},navigation,di} +mkdir -p feature-auth/src/test/kotlin/com/example/feature/auth +``` + +**Step 2: Configure build.gradle.kts** +Use the Feature Module build file template in `references/gradle-setup.md`. +It includes the feature convention plugins, core module dependencies, Navigation3, +and test bundles. + +**Step 3: Register in settings.gradle.kts** +```kotlin +include(":feature-auth") +``` + +**Step 4: Create navigation components** +```kotlin +// feature-auth/navigation/AuthDestination.kt +import androidx.compose.runtime.Immutable +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +@Immutable +sealed interface AuthDestination : NavKey { + @Serializable + data object Login : AuthDestination + + @Serializable + data object Register : AuthDestination + + @Serializable + data object ForgotPassword : AuthDestination + + @Serializable + data class Profile(val userId: String) : AuthDestination +} + +// feature-auth/navigation/AuthNavigator.kt +interface AuthNavigator { + fun navigateToRegister() + fun navigateToForgotPassword() + fun navigateBack() + fun navigateToProfile(userId: String) + fun navigateToMainApp() +} + +// feature-auth/navigation/AuthGraph.kt +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey + +fun EntryProviderScope.authGraph( + authNavigator: AuthNavigator +) { + entry { + LoginScreen( + onLoginSuccess = { user -> + authNavigator.navigateToMainApp() + }, + onRegisterClick = { + authNavigator.navigateToRegister() + }, + onForgotPasswordClick = { + authNavigator.navigateToForgotPassword() + } + ) + } + + entry { + RegisterScreen( + onRegisterSuccess = { user -> + authNavigator.navigateToMainApp() + }, + onNavigateToLogin = { + authNavigator.navigateBack() + } + ) + } + + entry { + ForgotPasswordScreen( + onResetSuccess = { + authNavigator.navigateBack() + }, + onNavigateBack = { + authNavigator.navigateBack() + } + ) + } + + entry { key -> + ProfileScreen( + userId = key.userId, + onNavigateBack = { + authNavigator.navigateBack() + } + ) + } +} +``` + +### 2. Create Core Module + +Required directory layout for a `core/domain` module: + +``` +core/domain/ + build.gradle.kts # Apply Core Domain template from references/gradle-setup.md + src/main/kotlin/com/example/core/domain/{model,repository,usecase}/ + src/test/kotlin/com/example/core/domain/ +``` + +The build file applies the Core Domain template from `references/gradle-setup.md` (pure Kotlin + serialization + test deps). Domain models, repository interfaces, and use cases go under `core/domain`; pattern details: `references/architecture.md` → Domain Layer. + +### 3. Create App Module Configuration + +Required wiring in the `:app` module: + +- `build.gradle.kts`: apply the App module template from `references/gradle-setup.md` (feature/core wiring, Navigation 3, Hilt). +- `NavigationState.kt` and `Navigator.kt`: see `references/android-navigation.md` → "Navigation 3 State Management". +- `AppNavigation.kt`: see `references/android-navigation.md` → "App Navigation Setup" (`NavigationSuiteScaffold`, `TopLevelRoute`, navigator implementations, icon resources). + +## Navigation Coordination + +For Navigation3 quick start, app navigation setup, state management (`NavigationState`, `Navigator`), +key principles, and migration guidance, see `references/android-navigation.md`. + +## Build Configuration + +Convention plugin definitions and examples live in `references/gradle-setup.md` +so all build logic stays centralized in one place. diff --git a/DeviceMasker-main/.agents/skills/claude-android-ninja/references/testing.md b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/testing.md new file mode 100644 index 000000000..051bf478c --- /dev/null +++ b/DeviceMasker-main/.agents/skills/claude-android-ninja/references/testing.md @@ -0,0 +1,2536 @@ +# Testing Patterns + +Required: hand-written fakes (no mocking libraries) in feature/core modules; Google Truth for assertions; Turbine for `Flow`; Hilt + Robolectric/Compose UI for integration. MockK is permitted only inside the `app` module for Navigation 3 framework types. Layered targets follow [architecture.md](/references/architecture.md) and [modularization.md](/references/modularization.md). + +## Table of Contents +1. [Testing Philosophy](#testing-philosophy) +2. [Test Doubles](#test-doubles) +3. [ViewModel Tests](#viewmodel-tests) +4. [Repository Tests](#repository-tests) +5. [Coroutine Testing](#coroutine-testing) +6. [Hilt Testing](#hilt-testing) +7. [Robolectric and SDK 37 (Android 17)](#robolectric-and-sdk-37-android-17) +8. [Room Database Testing](#room-database-testing) +9. [SavedStateHandle Testing](#savedstatehandle-testing) +10. [Navigation Tests](#navigation-tests) +11. [Compose Stability Testing](#testing-compose-stability-annotations) +12. [UI Tests](#ui-tests) +13. [Agent automation (ADB and UIAutomator)](#agent-automation-adb-and-uiautomator) +14. [Pre-release UI state checklist](#pre-release-ui-state-checklist) +15. [Screenshot Testing](#screenshot-testing) +16. [Performance Benchmarks](#performance-benchmarks) +17. [Test Utilities](#test-utilities) +18. [Rules](#rules) +19. [Paging 3 Testing](#paging-3-testing) +20. [Localization Testing](#localization-testing) + +## Testing Philosophy + +### No Mocking Libraries + +Required: +- **Feature modules**: hand-written fakes implementing the production interface; no mocking libraries. +- **Core modules**: fakes plus Room in-memory databases. +- **App module**: MockK is permitted **only** for Navigation 3 framework types (`NavigationState`, `Navigator`). +- Fakes carry real state and test hooks; never stub-only. +- Use Google Truth for assertions. + +### Test Doubles Naming Convention + +- **Fake** prefix: Working implementations with test hooks (e.g., `FakeAuthRepository`) +- Used in production test code that runs against realistic implementations +- Contains business logic and state management + +### Test Types by Module + +| Module | Test Type | Location | Purpose | +|-----------------|-------------------|--------------------|---------------------------| +| Feature modules | Unit tests | `src/test/` | ViewModel, UI logic | +| Core/Domain | Unit tests | `src/test/` | Use Cases, business logic | +| Core/Data | Integration tests | `src/test/` | Repository, DataSource | +| Core/UI | UI tests | `src/androidTest/` | Shared components | +| App module | Navigation tests | `src/test/` | Navigator implementations | + +## Test Doubles + +### Fake Repository Pattern (in `core:testing` module) + +```kotlin +// core/testing/src/main/kotlin/com/example/testing/auth/ +class FakeAuthRepository : AuthRepository { + + private val authStateFlow = MutableStateFlow(AuthState.Unauthenticated) + private val authEventsFlow = MutableSharedFlow() + private val users = mutableMapOf() + private val authTokens = mutableMapOf() + + // Test control hooks + var shouldFailLogin = false + var shouldFailRegister = false + var loginDelay = 0.seconds + var networkError: Exception? = null + + // Test setup methods + fun sendAuthState(authState: AuthState) { + authStateFlow.value = authState + } + + fun addUser(user: User) { + users[user.id] = user + } + + fun setAuthToken(email: String, token: AuthToken) { + authTokens[email] = token + } + + fun sendAuthEvent(event: AuthEvent) { + authEventsFlow.tryEmit(event) + } + + // Interface implementation + override suspend fun login(email: String, password: String): Result { + if (loginDelay > 0.seconds) { + delay(loginDelay) + } + + if (shouldFailLogin) { + return Result.failure(networkError ?: Exception("Login failed")) + } + + return authTokens[email]?.let { Result.success(it) } + ?: Result.failure(Exception("Invalid credentials")) + } + + override suspend fun register(user: User): Result { + if (shouldFailRegister) { + return Result.failure(networkError ?: Exception("Registration failed")) + } + + users[user.id] = user + return Result.success(Unit) + } + + override fun observeAuthState(): Flow = authStateFlow + + override fun observeAuthEvents(): Flow = authEventsFlow + + override suspend fun resetPassword(email: String): Result { + return Result.success(Unit) + } + + override suspend fun refreshSession(): Result { + return Result.success(Unit) + } + + // Test helpers + fun reset() { + shouldFailLogin = false + shouldFailRegister = false + loginDelay = 0.seconds + networkError = null + users.clear() + authTokens.clear() + authStateFlow.value = AuthState.Unauthenticated + } +} +``` + +### Fake Navigator Pattern + +```kotlin +// core/testing/src/main/kotlin/com/example/testing/navigation/ +class FakeAuthNavigator : AuthNavigator { + + private val _navigationEvents = mutableListOf() + val navigationEvents: List get() = _navigationEvents + + // Interface implementation with tracking + override fun navigateToRegister() { + _navigationEvents.add("navigateToRegister") + } + + override fun navigateToForgotPassword() { + _navigationEvents.add("navigateToForgotPassword") + } + + override fun navigateBack() { + _navigationEvents.add("navigateBack") + } + + override fun navigateToProfile(userId: String) { + _navigationEvents.add("navigateToProfile:$userId") + } + + override fun navigateToMainApp() { + _navigationEvents.add("navigateToMainApp") + } + + override fun navigateToVerifyEmail(token: String) { + _navigationEvents.add("navigateToVerifyEmail:$token") + } + + override fun navigateToResetPassword(token: String) { + _navigationEvents.add("navigateToResetPassword:$token") + } + + // Test helpers + fun clearEvents() { + _navigationEvents.clear() + } + + fun getLastEvent(): String? = _navigationEvents.lastOrNull() +} +``` + +### UseCase Setup Pattern + +Use real use cases wired to fake dependencies so you exercise production logic: + +```kotlin +@Before +fun setup() { + fakeAuthRepository = FakeAuthRepository() + loginUseCase = LoginUseCase(fakeAuthRepository) + registerUseCase = RegisterUseCase(fakeAuthRepository) +} +``` + +## ViewModel Tests + +### AuthViewModel Test with Fakes + +```kotlin +// feature-auth/src/test/kotlin/com/example/feature/auth/AuthViewModelTest.kt +import com.google.common.truth.Truth.assertThat + +class AuthViewModelTest { + + @get:Rule + val dispatcherRule = TestDispatcherRule() + + private lateinit var fakeAuthRepository: FakeAuthRepository + private lateinit var loginUseCase: LoginUseCase + private lateinit var registerUseCase: RegisterUseCase + private lateinit var resetPasswordUseCase: ResetPasswordUseCase + private lateinit var viewModel: AuthViewModel + + @Before + fun setup() { + fakeAuthRepository = FakeAuthRepository() + loginUseCase = LoginUseCase(fakeAuthRepository) + registerUseCase = RegisterUseCase(fakeAuthRepository) + resetPasswordUseCase = ResetPasswordUseCase(fakeAuthRepository) + + viewModel = AuthViewModel( + loginUseCase = loginUseCase, + registerUseCase = registerUseCase, + resetPasswordUseCase = resetPasswordUseCase + ) + } + + @Test + fun `initial state is LoginForm`() = runTest { + // Assert + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(AuthUiState.LoginForm::class.java) + } + + @Test + fun `when email is changed, ui state updates email`() = runTest { + // Arrange + val testEmail = "test@example.com" + + // Act + viewModel.onAction(AuthAction.EmailChanged(testEmail)) + + // Assert + val state = viewModel.uiState.value as AuthUiState.LoginForm + assertThat(state.email).isEqualTo(testEmail) + } + + @Test + fun `when login clicked with valid credentials, state becomes Loading then Success`() = runTest { + // Arrange + val testEmail = "test@example.com" + val testPassword = "password123" + fakeAuthRepository.setAuthToken( + testEmail, + AuthToken("test-token", User("1", testEmail, "Test User")) + ) + + viewModel.onAction(AuthAction.EmailChanged(testEmail)) + viewModel.onAction(AuthAction.PasswordChanged(testPassword)) + + // Act + viewModel.onAction(AuthAction.LoginClicked) + + // Assert - Check loading state + val loadingState = viewModel.uiState.value as AuthUiState.LoginForm + assertThat(loadingState.isLoading).isTrue() + + // Wait for async operation + advanceUntilIdle() + + // Assert - Check success state + val successState = viewModel.uiState.value + assertThat(successState).isInstanceOf(AuthUiState.Success::class.java) + } + + @Test + fun `when login fails, state becomes Error`() = runTest { + // Arrange + fakeAuthRepository.shouldFailLogin = true + + viewModel.onAction(AuthAction.EmailChanged("test@example.com")) + viewModel.onAction(AuthAction.PasswordChanged("wrong")) + + // Act + viewModel.onAction(AuthAction.LoginClicked) + advanceUntilIdle() + + // Assert + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(AuthUiState.Error::class.java) + } + + @Test + fun `when RegisterClicked, state becomes RegisterForm`() = runTest { + // Act + viewModel.onAction(AuthAction.RegisterClicked) + + // Assert + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(AuthUiState.RegisterForm::class.java) + } + + @Test + fun `when ForgotPasswordClicked, state becomes ForgotPasswordForm`() = runTest { + // Act + viewModel.onAction(AuthAction.ForgotPasswordClicked) + + // Assert + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(AuthUiState.ForgotPasswordForm::class.java) + } + + @Test + fun `when Retry action called after error, state returns to LoginForm`() = runTest { + // Arrange - cause an error + fakeAuthRepository.shouldFailLogin = true + viewModel.onAction(AuthAction.EmailChanged("test@example.com")) + viewModel.onAction(AuthAction.PasswordChanged("wrong")) + viewModel.onAction(AuthAction.LoginClicked) + advanceUntilIdle() + + // Verify error state + assertThat(viewModel.uiState.value).isInstanceOf(AuthUiState.Error::class.java) + + // Act + viewModel.onAction(AuthAction.Retry) + + // Assert + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(AuthUiState.LoginForm::class.java) + } + + @Test + fun `when ClearError action called, error is cleared and form is reset`() = runTest { + // Arrange - cause an error + fakeAuthRepository.shouldFailLogin = true + viewModel.onAction(AuthAction.EmailChanged("test@example.com")) + viewModel.onAction(AuthAction.PasswordChanged("wrong")) + viewModel.onAction(AuthAction.LoginClicked) + advanceUntilIdle() + + // Act + viewModel.onAction(AuthAction.ClearError) + + // Assert + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(AuthUiState.LoginForm::class.java) + val loginForm = state as AuthUiState.LoginForm + assertThat(loginForm.email).isEmpty() + assertThat(loginForm.password).isEmpty() + assertThat(loginForm.emailError).isNull() + assertThat(loginForm.passwordError).isNull() + } + + @Test + fun `when login form has validation errors, error messages are set`() = runTest { + // Arrange + viewModel.onAction(AuthAction.EmailChanged("invalid-email")) + viewModel.onAction(AuthAction.PasswordChanged("")) + + // Act + viewModel.onAction(AuthAction.LoginClicked) + advanceUntilIdle() + + // Assert + val state = viewModel.uiState.value as AuthUiState.LoginForm + assertThat(state.emailError).isNotNull() + assertThat(state.passwordError).isNotNull() + } +} +``` + +### Test Dispatcher Rule (in `core:testing`) + +See [Coroutine Testing → Test Dispatcher Rule](#test-dispatcher-rule-in-coretesting-1) for the full implementation. + +### Testing StateFlow with Turbine and Truth + +Required: Turbine for multi-emission `Flow` assertions; `advanceUntilIdle()` for simple async completion. + +**When to use Turbine:** +- Testing multiple emissions from a Flow +- Verifying emission order and values +- Testing Flow transformations + +**When to use `advanceUntilIdle()`:** +- Testing final StateFlow value after operation +- Simple async operations with one result +- No need to inspect intermediate states + +```kotlin +import com.google.common.truth.Truth.assertThat +import app.cash.turbine.test + +@Test +fun `uiState emits correct states during login flow`() = runTest { + // Arrange + fakeAuthRepository.setAuthToken( + "test@example.com", + AuthToken("test-token", User("1", "test@example.com", "Test User")) + ) + + viewModel.uiState.test { + // Initial state + assertThat(awaitItem()).isInstanceOf(AuthUiState.LoginForm::class.java) + + // Trigger login + viewModel.onAction(AuthAction.EmailChanged("test@example.com")) + viewModel.onAction(AuthAction.PasswordChanged("password123")) + viewModel.onAction(AuthAction.LoginClicked) + + // Should emit Loading state + val loadingState = awaitItem() + assertThat(loadingState).isInstanceOf(AuthUiState.LoginForm::class.java) + assertThat((loadingState as AuthUiState.LoginForm).isLoading).isTrue() + + // Should emit Success state + val successState = awaitItem() + assertThat(successState).isInstanceOf(AuthUiState.Success::class.java) + assertThat((successState as AuthUiState.Success).user.email).isEqualTo("test@example.com") + + cancelAndIgnoreRemainingEvents() + } +} + +@Test +fun `uiState emits Loading, Error when login fails`() = runTest { + // Arrange + fakeAuthRepository.shouldFailLogin = true + + viewModel.uiState.test { + // Skip initial state + skipItems(1) + + viewModel.onAction(AuthAction.EmailChanged("test@example.com")) + viewModel.onAction(AuthAction.PasswordChanged("wrong")) + viewModel.onAction(AuthAction.LoginClicked) + + // Should emit Loading state + val loadingState = awaitItem() as AuthUiState.LoginForm + assertThat(loadingState.isLoading).isTrue() + + // Should emit Error state + val errorState = awaitItem() + assertThat(errorState).isInstanceOf(AuthUiState.Error::class.java) + assertThat((errorState as AuthUiState.Error).message).isNotEmpty() + assertThat(errorState.canRetry).isTrue() + + cancelAndIgnoreRemainingEvents() + } +} +``` + +## Repository Tests + +### Testing AuthRepository Implementation with Truth + +```kotlin +// core/data/src/test/kotlin/com/example/data/auth/AuthRepositoryImplTest.kt +import com.google.common.truth.Truth.assertThat + +class AuthRepositoryImplTest { + + private lateinit var fakeLocalDataSource: FakeAuthLocalDataSource + private lateinit var fakeRemoteDataSource: FakeAuthRemoteDataSource + private lateinit var authMapper: AuthMapper + private lateinit var repository: AuthRepositoryImpl + + @Before + fun setup() { + fakeLocalDataSource = FakeAuthLocalDataSource() + fakeRemoteDataSource = FakeAuthRemoteDataSource() + authMapper = AuthMapper() + + repository = AuthRepositoryImpl( + localDataSource = fakeLocalDataSource, + remoteDataSource = fakeRemoteDataSource, + authMapper = authMapper + ) + } + + @Test + fun `login success saves token and user to local storage`() = runTest { + // Arrange + val testEmail = "test@example.com" + val testPassword = "password123" + val expectedToken = AuthTokenResponse("test-token", NetworkUser("1", testEmail, "Test User")) + fakeRemoteDataSource.setLoginResponse(expectedToken) + + // Act + val result = repository.login(testEmail, testPassword) + + // Assert + assertThat(result.isSuccess).isTrue() + assertThat(result.getOrNull()?.value).isEqualTo(expectedToken.token) + + // Verify local storage was updated + val savedToken = fakeLocalDataSource.getAuthToken() + assertThat(savedToken).isEqualTo(expectedToken.token) + + val savedUser = fakeLocalDataSource.getUser() + assertThat(savedUser?.email).isEqualTo(expectedToken.user.email) + } + + @Test + fun `login failure returns error result`() = runTest { + // Arrange + val testEmail = "test@example.com" + val testPassword = "wrong-password" + fakeRemoteDataSource.shouldFailLogin = true + + // Act + val result = repository.login(testEmail, testPassword) + + // Assert + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()?.message).contains("Invalid") + } + + @Test + fun `observeAuthState emits Authenticated when token exists`() = runTest { + // Arrange + fakeLocalDataSource.setAuthToken("test-token") + fakeLocalDataSource.setUser(UserEntity("1", "test@example.com", "Test User")) + + // Act & Assert + repository.observeAuthState().test { + val authState = awaitItem() + assertThat(authState).isInstanceOf(AuthState.Authenticated::class.java) + assertThat((authState as AuthState.Authenticated).user.id).isEqualTo("1") + assertThat(authState.user.email).isEqualTo("test@example.com") + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `observeAuthState emits Unauthenticated when no token exists`() = runTest { + // Act & Assert + repository.observeAuthState().test { + val authState = awaitItem() + assertThat(authState).isInstanceOf(AuthState.Unauthenticated::class.java) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `observeAuthState emits Error when local data source fails`() = runTest { + // Arrange + fakeLocalDataSource.shouldFail = true + + // Act & Assert + repository.observeAuthState().test { + val authState = awaitItem() + assertThat(authState).isInstanceOf(AuthState.Error::class.java) + assertThat((authState as AuthState.Error).message).isNotEmpty() + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `register success saves user to local storage`() = runTest { + // Arrange + val testUser = User("1", "test@example.com", "Test User") + fakeRemoteDataSource.setRegisterResponse(Unit) + + // Act + val result = repository.register(testUser) + + // Assert + assertThat(result.isSuccess).isTrue() + val savedUser = fakeLocalDataSource.getUser() + assertThat(savedUser?.email).isEqualTo(testUser.email) + assertThat(savedUser?.name).isEqualTo(testUser.name) + } +} +``` + +## Coroutine Testing + +### Test Dispatcher Rule (in `core:testing`) + +Use a custom JUnit rule to set `Dispatchers.Main` to a test dispatcher for all coroutine tests. + +```kotlin +// core/testing/src/main/kotlin/com/example/testing/rule/TestDispatcherRule.kt +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +class TestDispatcherRule( + private val testDispatcher: TestDispatcher = StandardTestDispatcher(), +) : TestWatcher() { + + override fun starting(description: Description) { + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} +``` + +### Testing with `runTest` and Shared Scheduler + +Use `runTest` for coroutine tests. Share the same scheduler across test dispatchers for predictable timing. + +```kotlin +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import com.google.common.truth.Truth.assertThat + +class AuthRepositoryTest { + + @get:Rule + val dispatcherRule = TestDispatcherRule() + + @Test + fun `login updates auth state`() = runTest { + // Arrange + val testDispatcher = UnconfinedTestDispatcher(testScheduler) + val repository = AuthRepository( + remote = FakeAuthRemoteDataSource(), + ioDispatcher = testDispatcher + ) + + // Act + repository.login("user@example.com", "password") + + // Assert + assertThat(repository.isLoggedIn()).isTrue() + } +} +``` + +### Using `advanceUntilIdle()` for Async Operations + +Use `advanceUntilIdle()` to wait for all pending coroutines to complete in tests. + +```kotlin +@Test +fun `login triggers loading then success state`() = runTest { + // Arrange + val viewModel = AuthViewModel(loginUseCase, savedStateHandle) + + // Act + viewModel.onAction(AuthAction.LoginClicked) + + // Assert loading state + val loadingState = viewModel.uiState.value + assertThat((loadingState as AuthUiState.LoginForm).isLoading).isTrue() + + // Wait for async work to complete + advanceUntilIdle() + + // Assert final state + val finalState = viewModel.uiState.value + assertThat(finalState).isInstanceOf(AuthUiState.Success::class.java) +} +``` + +### Testing Delays and Timeouts with `advanceTimeBy()` + +Use `advanceTimeBy()` to test time-dependent coroutine logic without actually waiting. + +```kotlin +@Test +fun `session refresh happens after 30 minutes`() = runTest { + // Arrange + val fakeAuthStore = FakeAuthStore() + val sessionRefresher = AuthSessionRefresher( + authStore = fakeAuthStore, + externalScope = this, + ioDispatcher = UnconfinedTestDispatcher(testScheduler) + ) + + // Act + sessionRefresher.startPeriodicRefresh() + + // Fast-forward 30 minutes + advanceTimeBy(30.minutes) + + // Assert + assertThat(fakeAuthStore.refreshCallCount).isEqualTo(1) + + // Fast-forward another 30 minutes + advanceTimeBy(30.minutes) + + // Assert second refresh + assertThat(fakeAuthStore.refreshCallCount).isEqualTo(2) +} +``` + +### Testing Timeout Behavior + +Test `withTimeout` and `withTimeoutOrNull` behavior using virtual time. + +```kotlin +@Test +fun `biometric authentication times out after 30 seconds`() = runTest { + // Arrange + val slowBiometricSdk = FakeBiometricSdk(responseDelay = 40.seconds) + val repository = BiometricAuthRepository( + biometricSdk = slowBiometricSdk, + ioDispatcher = UnconfinedTestDispatcher(testScheduler) + ) + + // Act + val result = repository.authenticate() + + // Fast-forward past the timeout + advanceTimeBy(35.seconds) + + // Assert - should return null due to timeout + assertThat(result).isNull() +} + +@Test +fun `printer returns timeout result when operation hangs`() = runTest { + // Arrange + val hangingPrinterSdk = FakePrinterSdk(hangOnPrint = true) + val repository = HardwarePrinterRepository( + printerSdk = hangingPrinterSdk, + ioDispatcher = UnconfinedTestDispatcher(testScheduler) + ) + + // Act + val resultDeferred = async { repository.print(testDocument) } + + // Fast-forward past the 60s timeout + advanceTimeBy(65.seconds) + val result = resultDeferred.await() + + // Assert + assertThat(result).isEqualTo(PrintResult.Timeout) +} +``` + +### Checking Virtual Time with `currentTime` + +Use `currentTime` to verify time progression in tests. + +```kotlin +@Test +fun `exponential backoff delays increase correctly`() = runTest { + // Arrange + val retryManager = AuthRetryManager() + val startTime = currentTime + + // Act & Assert + retryManager.retryWithBackoff(attempt = 1) + assertThat(currentTime - startTime).isEqualTo(1000L) // 1 second + + retryManager.retryWithBackoff(attempt = 2) + assertThat(currentTime - startTime).isEqualTo(3000L) // +2 seconds + + retryManager.retryWithBackoff(attempt = 3) + assertThat(currentTime - startTime).isEqualTo(7000L) // +4 seconds +} +``` + +### Testing Flow Emissions with Turbine + +Use Turbine library for testing Flow emissions over time. + +```kotlin +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat + +@Test +fun `auth state flow emits correct states`() = runTest { + // Arrange + val fakeDataSource = FakeAuthDataSource() + val repository = AuthRepository(fakeDataSource, UnconfinedTestDispatcher(testScheduler)) + + // Act & Assert + repository.observeAuthState().test { + // Initial state + assertThat(awaitItem()).isInstanceOf(AuthState.Unauthenticated::class.java) + + // Trigger login + repository.login("user@example.com", "password") + advanceUntilIdle() + + // Should emit Authenticated + val authState = awaitItem() + assertThat(authState).isInstanceOf(AuthState.Authenticated::class.java) + assertThat((authState as AuthState.Authenticated).user.email).isEqualTo("user@example.com") + + cancelAndIgnoreRemainingEvents() + } +} + +@Test +fun `session refresh flow emits at correct intervals`() = runTest { + // Arrange + val fakeStore = FakeAuthStore() + val refresher = AuthSessionRefresher(fakeStore, this, UnconfinedTestDispatcher(testScheduler)) + + // Act & Assert + fakeStore.sessionUpdates.test { + refresher.startPeriodicRefresh() + + // First refresh happens immediately + assertThat(awaitItem()).isNotNull() + + // Advance 30 minutes + advanceTimeBy(30.minutes) + assertThat(awaitItem()).isNotNull() + + // Advance another 30 minutes + advanceTimeBy(30.minutes) + assertThat(awaitItem()).isNotNull() + + cancelAndIgnoreRemainingEvents() + } +} + +@Test +fun `channel events are received correctly`() = runTest { + // Arrange + val viewModel = AuthViewModel(loginUseCase, savedStateHandle) + + // Act & Assert + viewModel.navigationEvents.test { + viewModel.login() + advanceUntilIdle() + + assertThat(awaitItem()).isEqualTo(AuthNavigationEvent.LoginSuccess) + + cancelAndIgnoreRemainingEvents() + } +} +``` + +### Testing Cancellation + +Test that coroutines respond to cancellation correctly. + +```kotlin +@Test +fun `auth log upload stops on cancellation`() = runTest { + // Arrange + val fakeUploader = FakeLogUploader() + val uploader = AuthLogUploader(fakeUploader) + val job = launch { + uploader.upload(listOf(file1, file2, file3, file4, file5)) + } + + // Act - cancel after some uploads + advanceTimeBy(100L) + job.cancel() + advanceUntilIdle() + + // Assert - not all files were uploaded + assertThat(fakeUploader.uploadedFiles.size).isLessThan(5) +} + +@Test +fun `camera cleanup happens even when cancelled`() = runTest { + // Arrange + val fakeCamera = FakeCamera() + val repository = CameraRepository(fakeCamera, UnconfinedTestDispatcher(testScheduler)) + + // Act - start capture then cancel + val job = launch { + try { + repository.capturePhoto() + } catch (e: CancellationException) { + // Expected + } + } + + advanceTimeBy(50L) + job.cancel() + advanceUntilIdle() + + // Assert - camera was closed despite cancellation (NonCancellable cleanup) + assertThat(fakeCamera.isClosed).isTrue() +} +``` + +### Coroutine test rules + +Required: +- Wrap every coroutine test in `runTest { }`. +- Share the scheduler: `UnconfinedTestDispatcher(testScheduler)` or `StandardTestDispatcher(testScheduler)`. +- Inject dispatchers in production code; never hardcode `Dispatchers.IO` / `Dispatchers.Default`. +- `advanceUntilIdle()` before assertions; `advanceTimeBy(...)` for delay/timeout coverage. +- Cover cancellation paths and cleanup of resources held inside `NonCancellable`/`finally` blocks. + +### Dispatcher Choices in Tests + +| Dispatcher | Use when | +|-----------------------------|---------------------------------------------------------------| +| `UnconfinedTestDispatcher` | Default - eager execution, synchronous-style assertions. | +| `StandardTestDispatcher` | Need explicit ordering or virtual-time stepping. | + +```kotlin +val unconfinedDispatcher = UnconfinedTestDispatcher(testScheduler) +val standardDispatcher = StandardTestDispatcher(testScheduler) +``` + +## Hilt Testing + +### Testing Hilt-Injected ViewModels + +```kotlin +// feature-auth/src/test/kotlin/com/example/feature/auth/AuthViewModelHiltTest.kt +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltTestApplication +import dagger.hilt.android.testing.BindValue +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@HiltAndroidTest +@RunWith(RobolectricTestRunner::class) +@Config(application = HiltTestApplication::class) +class AuthViewModelHiltTest { + + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val dispatcherRule = TestDispatcherRule() + + // Replace real implementation with fake for testing + @BindValue + @JvmField + val authRepository: AuthRepository = FakeAuthRepository() + + @Inject + lateinit var viewModel: AuthViewModel + + @Before + fun setup() { + hiltRule.inject() + } + + @Test + fun `ViewModel receives injected fake repository`() = runTest { + // The ViewModel is injected with FakeAuthRepository via @BindValue + viewModel.onAction(AuthAction.LoginClicked) + advanceUntilIdle() + + // Verify fake was used + assertThat((authRepository as FakeAuthRepository).shouldFailLogin).isFalse() + } +} +``` + +### Custom Test Module + +```kotlin +// feature-auth/src/test/kotlin/com/example/feature/auth/di/TestAuthModule.kt +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [AuthModule::class] // Replace production module +) +object TestAuthModule { + + @Provides + @Singleton + fun provideAuthRepository(): AuthRepository = FakeAuthRepository() + + @Provides + @Singleton + fun provideAuthApi(): AuthApi = FakeAuthApi() +} +``` + +### Testing Without Hilt + +For unit tests that don't need DI, construct dependencies manually: + +```kotlin +@Test +fun `ViewModel without Hilt injection`() = runTest { + // Arrange - manual construction + val fakeRepo = FakeAuthRepository() + val viewModel = AuthViewModel( + loginUseCase = LoginUseCase(fakeRepo), + registerUseCase = RegisterUseCase(fakeRepo), + resetPasswordUseCase = ResetPasswordUseCase(fakeRepo) + ) + + // Test normally + viewModel.onAction(AuthAction.LoginClicked) + advanceUntilIdle() + + assertThat(viewModel.uiState.value).isInstanceOf(AuthUiState.Error::class.java) +} +``` + +## Robolectric and SDK 37 (Android 17) + +Use when: the module contains `@RunWith(RobolectricTestRunner::class)` tests. + +Skip entirely when: tests are plain JVM unit tests (ViewModels, coroutines, fakes) with no Robolectric runner. + +The catalog pins Robolectric `4.16.1`, which targets SDK 35 and SDK 36 (Baklava). No Robolectric release that targets SDK 37 has shipped yet. + +Required: +- Compile against `compileSdk = 37` (catalog default) but annotate Robolectric tests with `@Config(sdk = [Build.VERSION_CODES.BAKLAVA])` until a Robolectric release that supports SDK 37 ships. Track [Robolectric releases](https://github.com/robolectric/robolectric/releases) and bump the catalog `robolectric` pin the moment one announces SDK 37 support. +- Run JVM unit tests on JDK 21 when `sdk = 36` is in effect. Robolectric 4.16+ refuses to run on JDK 17 at SDK 36. +- Stay on Robolectric `4.13` or newer regardless of the `@Config` SDK. Earlier releases predate the Android 17 `MessageQueue` rewrite and crash on launch when the platform's queue runs. + +```kotlin +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.BAKLAVA]) +class FooTest { /* ... */ } +``` + +Espresso `3.7.0` (catalog-pinned) is the latest stable AndroidX Test release; instrumented tests at target SDK 37 require no Espresso-side changes. + +## Room Database Testing + +Room 3 requires a [`SQLiteDriver`](https://developer.android.com/kotlin/multiplatform/sqlite#sqlite-driver) on the database builder (the `app.android.room` convention adds `sqlite-bundled`). Use [`BundledSQLiteDriver`](https://developer.android.com/reference/kotlin/androidx/sqlite/driver/bundled/BundledSQLiteDriver) in tests the same way as in production code. + +For **migration** instrumentation tests, add **`androidTestImplementation(libs.room3.testing)`** and ensure exported schemas are available to the test APK (the Room Gradle plugin can copy schemas into `androidTest` assets; see [Test migrations](https://developer.android.com/training/data-storage/room/migrating-db-versions#test) and [`MigrationTestHelper`](https://developer.android.com/reference/kotlin/androidx/room3/testing/MigrationTestHelper)). + +### In-Memory Database for Tests + +```kotlin +// core/database/src/androidTest/kotlin/com/example/database/AuthDaoTest.kt +import android.content.Context +import androidx.room3.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before + +class AuthDaoTest { + + private lateinit var database: AppDatabase + private lateinit var authDao: AuthDao + + @Before + fun createDb() { + val context = ApplicationProvider.getApplicationContext() + database = Room.inMemoryDatabaseBuilder(context) + .setDriver(BundledSQLiteDriver()) + .build() + authDao = database.authDao() + } + + @After + fun closeDb() { + database.close() + } + + @Test + fun insertAndRetrieveAuthToken() = runTest { + // Arrange + val authToken = AuthTokenEntity( + token = "test-token", + userId = "user-123", + expiresAt = Clock.System.now().plus(1.hours).toEpochMilliseconds() + ) + + // Act + authDao.insertAuthToken(authToken) + val retrieved = authDao.getAuthToken() + + // Assert + assertThat(retrieved).isNotNull() + assertThat(retrieved?.token).isEqualTo("test-token") + assertThat(retrieved?.userId).isEqualTo("user-123") + } + + @Test + fun observeAuthToken_emitsUpdates() = runTest { + // Arrange + val token1 = AuthTokenEntity("token-1", "user-1", 0) + val token2 = AuthTokenEntity("token-2", "user-2", 0) + + // Act & Assert + authDao.observeAuthToken().test { + // Initial state - null + assertThat(awaitItem()).isNull() + + // Insert first token + authDao.insertAuthToken(token1) + assertThat(awaitItem()?.token).isEqualTo("token-1") + + // Update with second token + authDao.insertAuthToken(token2) + assertThat(awaitItem()?.token).isEqualTo("token-2") + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun deleteAuthToken_removesData() = runTest { + // Arrange + val authToken = AuthTokenEntity("token", "user", 0) + authDao.insertAuthToken(authToken) + + // Act + authDao.deleteAuthToken() + + // Assert + val retrieved = authDao.getAuthToken() + assertThat(retrieved).isNull() + } + + @Test + fun getUserById_returnsCorrectUser() = runTest { + // Arrange + val user1 = UserEntity("1", "user1@example.com", "User One") + val user2 = UserEntity("2", "user2@example.com", "User Two") + authDao.insertUser(user1) + authDao.insertUser(user2) + + // Act + val retrieved = authDao.getUserById("2") + + // Assert + assertThat(retrieved).isNotNull() + assertThat(retrieved?.id).isEqualTo("2") + assertThat(retrieved?.email).isEqualTo("user2@example.com") + } +} +``` + +### Testing Database Migrations + +`MigrationTestHelper` APIs are **suspend** and return [`SQLiteConnection`](https://developer.android.com/reference/kotlin/androidx/sqlite/SQLiteConnection) (not `SupportSQLiteDatabase`). Use **`runBlocking`** (or another coroutine test harness) from instrumentation tests. Validate rows with **`prepare` / `step` / `getText`** (see [`SQLiteStatement`](https://developer.android.com/reference/kotlin/androidx/sqlite/SQLiteStatement)). + +```kotlin +// core/database/src/androidTest/kotlin/com/example/database/MigrationTest.kt +import androidx.room3.testing.MigrationTestHelper +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.sqlite.execSQL +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class MigrationTest { + + private val instrumentation = InstrumentationRegistry.getInstrumentation() + + @get:Rule + val helper = MigrationTestHelper( + instrumentation = instrumentation, + file = instrumentation.targetContext.getDatabasePath(TEST_DB), + driver = BundledSQLiteDriver(), + databaseClass = AppDatabase::class, + ) + + @Before + fun deleteDb() { + instrumentation.targetContext.deleteDatabase(TEST_DB) + } + + @Test + fun migrate1To2_containsCorrectData() = runBlocking { + helper.createDatabase(1).apply { + execSQL("INSERT INTO users VALUES ('1', 'test@example.com', 'Test User')") + close() + } + + val migrated = helper.runMigrationsAndValidate(2, listOf(MIGRATION_1_2)) + migrated.prepare("SELECT email FROM users WHERE id = '1'").use { stmt -> + assertThat(stmt.step()).isTrue() + assertThat(stmt.getText(0)).isEqualTo("test@example.com") + } + migrated.close() + } + + companion object { + private const val TEST_DB = "migration-test" + } +} +``` + +## SavedStateHandle Testing + +### Testing Navigation Arguments + +```kotlin +// feature-profile/src/test/kotlin/com/example/feature/profile/ProfileViewModelTest.kt +import androidx.lifecycle.SavedStateHandle +import com.google.common.truth.Truth.assertThat + +class ProfileViewModelTest { + + @get:Rule + val dispatcherRule = TestDispatcherRule() + + private lateinit var fakeUserRepository: FakeUserRepository + private lateinit var savedStateHandle: SavedStateHandle + private lateinit var viewModel: ProfileViewModel + + @Test + fun `ViewModel loads user from navigation argument`() = runTest { + // Arrange + val userId = "user-123" + savedStateHandle = SavedStateHandle(mapOf("userId" to userId)) + + val expectedUser = User(userId, "test@example.com", "Test User") + fakeUserRepository = FakeUserRepository().apply { + addUser(expectedUser) + } + + viewModel = ProfileViewModel( + userRepository = fakeUserRepository, + savedStateHandle = savedStateHandle + ) + + // Act + advanceUntilIdle() + + // Assert + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(ProfileUiState.Success::class.java) + assertThat((state as ProfileUiState.Success).user.id).isEqualTo(userId) + } + + @Test + fun `ViewModel handles missing navigation argument`() = runTest { + // Arrange - no userId in SavedStateHandle + savedStateHandle = SavedStateHandle() + fakeUserRepository = FakeUserRepository() + + // Act & Assert + val exception = assertThrows { + ProfileViewModel( + userRepository = fakeUserRepository, + savedStateHandle = savedStateHandle + ) + } + + assertThat(exception.message).contains("userId") + } + + @Test + fun `SavedStateHandle survives process death simulation`() = runTest { + // Arrange + val userId = "user-123" + savedStateHandle = SavedStateHandle(mapOf("userId" to userId)) + fakeUserRepository = FakeUserRepository() + + val viewModel = ProfileViewModel(fakeUserRepository, savedStateHandle) + + // Simulate state saving + val savedState = savedStateHandle.keys().associateWith { savedStateHandle.get(it) } + + // Simulate process death and restoration + val restoredHandle = SavedStateHandle(savedState) + val restoredViewModel = ProfileViewModel(fakeUserRepository, restoredHandle) + + // Assert - restored ViewModel has same userId + assertThat(restoredHandle.get("userId")).isEqualTo(userId) + } +} +``` + +### Testing State Persistence + +```kotlin +@Test +fun `form state is saved to SavedStateHandle`() = runTest { + // Arrange + savedStateHandle = SavedStateHandle() + viewModel = AuthViewModel( + loginUseCase = loginUseCase, + savedStateHandle = savedStateHandle + ) + + val testEmail = "test@example.com" + val testPassword = "password123" + + // Act + viewModel.onAction(AuthAction.EmailChanged(testEmail)) + viewModel.onAction(AuthAction.PasswordChanged(testPassword)) + + // Assert - state is saved + assertThat(savedStateHandle.get("email")).isEqualTo(testEmail) + assertThat(savedStateHandle.get("password")).isEqualTo(testPassword) +} +``` + +## Navigation Tests + +### Testing Navigator Implementations in App Module + +Navigation3 uses `NavigationState` and `Navigator` instead of `NavController`. Test navigator interfaces +with fake implementations. + +```kotlin +// app/src/test/kotlin/com/example/navigation/AppNavigatorsTest.kt +import com.google.common.truth.Truth.assertThat + +class AppNavigatorsTest { + + private lateinit var fakeAuthNavigator: FakeAuthNavigator + + @Before + fun setup() { + fakeAuthNavigator = FakeAuthNavigator() + } + + @Test + fun `FakeAuthNavigator tracks all navigation events`() { + // Act + fakeAuthNavigator.navigateToMainApp() + fakeAuthNavigator.navigateToRegister() + fakeAuthNavigator.navigateToProfile("user123") + fakeAuthNavigator.navigateBack() + + // Assert + assertThat(fakeAuthNavigator.navigationEvents).hasSize(4) + assertThat(fakeAuthNavigator.navigationEvents[0]).isEqualTo("navigateToMainApp") + assertThat(fakeAuthNavigator.navigationEvents[1]).isEqualTo("navigateToRegister") + assertThat(fakeAuthNavigator.navigationEvents[2]).isEqualTo("navigateToProfile:user123") + assertThat(fakeAuthNavigator.navigationEvents[3]).isEqualTo("navigateBack") + } + + @Test + fun `FakeAuthNavigator clearEvents works correctly`() { + // Arrange + fakeAuthNavigator.navigateToMainApp() + fakeAuthNavigator.navigateToRegister() + + // Pre-condition + assertThat(fakeAuthNavigator.navigationEvents).isNotEmpty() + + // Act + fakeAuthNavigator.clearEvents() + + // Assert + assertThat(fakeAuthNavigator.navigationEvents).isEmpty() + } + + @Test + fun `FakeAuthNavigator getLastEvent returns most recent navigation`() { + // Act + fakeAuthNavigator.navigateToRegister() + fakeAuthNavigator.navigateToProfile("user123") + + // Assert + assertThat(fakeAuthNavigator.getLastEvent()).isEqualTo("navigateToProfile:user123") + } +} +``` + +### Testing Navigation3 State + +```kotlin +// app/src/test/kotlin/com/example/navigation/NavigationStateTest.kt +import androidx.navigation3.runtime.NavKey +import com.google.common.truth.Truth.assertThat +import kotlinx.serialization.Serializable + +@Serializable +sealed interface TestRoute : NavKey { + @Serializable data object Home : TestRoute + @Serializable data object Profile : TestRoute + @Serializable data object Settings : TestRoute + @Serializable data class Detail(val id: String) : TestRoute +} + +class NavigationStateTest { + + @Test + fun `Navigator switches between top-level routes`() { + // Arrange + val topLevelRoutes = setOf(TestRoute.Home, TestRoute.Profile, TestRoute.Settings) + val state = NavigationState( + startRoute = TestRoute.Home, + topLevelRoute = mutableStateOf(TestRoute.Home), + backStacks = topLevelRoutes.associateWith { FakeNavBackStack(it) } + ) + val navigator = Navigator(state) + + // Act + navigator.navigate(TestRoute.Profile) + + // Assert + assertThat(state.topLevelRoute).isEqualTo(TestRoute.Profile) + } + + @Test + fun `Navigator adds child routes to current stack`() { + // Arrange + val topLevelRoutes = setOf(TestRoute.Home) + val homeStack = FakeNavBackStack(TestRoute.Home) + val state = NavigationState( + startRoute = TestRoute.Home, + topLevelRoute = mutableStateOf(TestRoute.Home), + backStacks = mapOf(TestRoute.Home to homeStack) + ) + val navigator = Navigator(state) + + // Act + navigator.navigate(TestRoute.Detail("123")) + + // Assert + assertThat(homeStack.entries).contains(TestRoute.Detail("123")) + } + + @Test + fun `Navigator goBack pops current stack`() { + // Arrange + val topLevelRoutes = setOf(TestRoute.Home) + val homeStack = FakeNavBackStack(TestRoute.Home).apply { + add(TestRoute.Detail("123")) + } + val state = NavigationState( + startRoute = TestRoute.Home, + topLevelRoute = mutableStateOf(TestRoute.Home), + backStacks = mapOf(TestRoute.Home to homeStack) + ) + val navigator = Navigator(state) + + // Act + navigator.goBack() + + // Assert + assertThat(homeStack.entries).doesNotContain(TestRoute.Detail("123")) + assertThat(homeStack.last()).isEqualTo(TestRoute.Home) + } +} + +// Fake NavBackStack for testing +class FakeNavBackStack(startRoute: T) { + val entries = mutableListOf(startRoute) + + fun add(route: T) { + entries.add(route) + } + + fun removeLastOrNull(): T? = entries.removeLastOrNull() + + fun last(): T = entries.last() +} +``` + +### Testing Compose Stability Annotations + +Required: assert `@Immutable` / `@Stable` on UI-owned models in unit tests before relying on Compose compiler stability output: + +```kotlin +// core/domain/src/test/kotlin/com/example/domain/model/StabilityTest.kt +import androidx.compose.runtime.Stable +import androidx.compose.runtime.Immutable +import com.google.common.truth.Truth.assertThat +import kotlin.reflect.full.findAnnotation + +class StabilityTest { + + @Test + fun `User model is annotated with @Immutable`() { + // Assert + val annotation = User::class.findAnnotation() + assertThat(annotation).isNotNull() + } + + @Test + fun `AuthRepository interface is annotated with @Stable`() { + // Assert + val annotation = AuthRepository::class.findAnnotation() + assertThat(annotation).isNotNull() + } + + @Test + fun `User model has only val properties`() { + // Get all properties + val properties = User::class.members.filterIsInstance>() + + // Assert all are val (immutable) + properties.forEach { property -> + assertThat(property is KMutableProperty<*>).isFalse() + } + } + + @Test + fun `UiState sealed interface types are @Immutable`() { + // Check all sealed subclasses + val subclasses = AuthUiState::class.sealedSubclasses + + subclasses.forEach { subclass -> + val annotation = subclass.findAnnotation() + assertThat(annotation).isNotNull() + } + } +} +``` + +Required after changing `@Immutable` / `@Stable` on UI-facing models: run Compose Compiler reports via the `composeStabilityAnalyzer` Gradle plugin ([gradle-setup.md](gradle-setup.md) → "Compose Stability Analyzer"). + +### Testing Deep Links + +Required: wait at least 20 seconds after `adb install` before the first `pm get-app-links` read - the verifier runs asynchronously. + +#### Launch deep links (`am start`) + +```bash +adb shell am start -W -a android.intent.action.VIEW \ + -d "https://example.com/products/abc123" \ + com.example.app + +adb shell am start -W -a android.intent.action.VIEW \ + -d "myapp://open/profile/user42" \ + com.example.app + +adb shell am start -W -a android.intent.action.VIEW \ + -d "https://example.com/search?query=shoes&category=footwear" \ + com.example.app + +adb shell am start -W -a android.intent.action.VIEW \ + --activity-new-task \ + -d "https://example.com/products/abc123" \ + com.example.app +``` + +#### Custom-scheme launch (`am start`) + +Required: when validating custom-scheme routing, run the `adb shell am start` line that uses `-d "myapp://open/profile/user42"` from Launch deep links (`am start`). + +Forbidden: treating a successful custom-scheme launch as proof of HTTPS App Links verification - `pm get-app-links` never inspects custom schemes; the disambiguation dialog and default-handler state apply only to `http`/`https` filters with `autoVerify`. + +Forbidden: security-critical flows (auth callback, payment return) on custom schemes in production - any package can register the same scheme (see [android-navigation.md → Custom-Scheme Deep Linking](android-navigation.md#custom-scheme-deep-linking)). + +#### App Links verification (`pm` + `dumpsys`) + +```bash +adb shell pm set-app-links --package com.example.app 0 all + +adb shell pm verify-app-links --re-verify com.example.app + +adb shell pm get-app-links com.example.app + +adb shell pm get-app-links --user cur com.example.app + +adb shell dumpsys package d +``` + +| Command | Use when | +|------------------------------------------|--------------------------------------------------------------------------------| +| `pm set-app-links --package 0 all` | Reset every domain to unselected before a clean re-verify. | +| `pm verify-app-links --re-verify ` | Force the verifier to re-fetch `assetlinks.json` after server changes. | +| `pm get-app-links ` | Read per-host verification state for the default user. | +| `pm get-app-links --user cur ` | Same as above when multiple users exist on the device. | +| `dumpsys package d` | Dump domain-preferred-apps for every package (alias: `domain-preferred-apps`). | + +#### Domain verification state legend + +Required: read the `Domain verification state:` block from `pm get-app-links` output before interpreting host status. + +| State | Meaning | +|---------------------|------------------------------------------------------------------------| +| `verified` | Digital Asset Links succeeded for that host. | +| `approved` | User or shell forced approval; not the same as automatic verification. | +| `denied` | User or shell forced denial. | +| `legacy_failure` | Legacy verifier rejected the host; reason not surfaced. | +| `migrated` | Result carried over from legacy verification. | +| `restored` | Approved after backup restore; assumed previously verified. | +| `system_configured` | OEM or policy pre-approved the domain. | +| `none` | No record yet - wait, re-run `--re-verify`, or confirm network. | +| `1024` or higher | Device-specific verifier error code; retry after network is stable. | + +Required: treat the hex suffix after `Status: always` in `dumpsys package d` as user preference metadata - it does not replace per-host `verified` / `none` lines from `pm get-app-links`. + +#### Pre-Android-12 verification compat + +Use when the app targets below API 31 and you need the Android-12+ verifier behaviour on an older test image: + +```bash +adb shell am compat enable 175408749 com.example.app +``` + +#### Digital Asset Links REST (no device) + +```bash +curl 'https://digitalassetlinks.googleapis.com/v1/statements:list?\ +source.web.site=https://example.com&\ +relation=delegate_permission/common.handle_all_urls' +``` + +Required: HTTP 200 JSON body with a non-empty `statements` array before expecting `verified` on device. + +#### Dynamic App Links REST validation + +Required when server-side `dynamic_app_link_components` exists: query with `return_relation_extensions=true` and assert the extension payload before writing device tests (see [android-navigation.md → Dynamic App Links](android-navigation.md#dynamic-app-links-android-15-api-35)). + +```bash +curl 'https://digitalassetlinks.googleapis.com/v1/statements:list?\ +source.web.site=https://example.com&\ +relation=delegate_permission/common.handle_all_urls&\ +return_relation_extensions=true' +``` + +Required: locate `dynamic_app_link_components` under the relation-extension map for `delegate_permission/common.handle_all_urls` inside a `statements[]` entry; assert it is a non-empty JSON array when dynamic rules are active. + +Forbidden: omitting `return_relation_extensions=true` when the test asserts dynamic path/query/fragment behaviour - the verifier omits that field without the flag. + +#### Dynamic rules device refresh + +Required after every deploy that edits `dynamic_app_link_components` in `assetlinks.json`: + +```bash +adb shell pm verify-app-links --re-verify com.example.app +adb shell pm get-app-links com.example.app +``` + +Required: every host that participates in dynamic routing shows `verified` in `pm get-app-links` output before closing the change (or document an intentional `approved` / `selected` user override from [Domain verification state legend](#domain-verification-state-legend)). + +#### Unit tests (parsing + stack) + +```kotlin +class DeepLinkParsingTest { + + @Test + fun `product deep link parses productId correctly`() { + val uri = "https://example.com/products/abc123".toUri() + val request = DeepLinkRequest(uri) + + val match = deepLinkPatterns.firstNotNullOfOrNull { pattern -> + DeepLinkMatcher(request, pattern).match() + } + + assertThat(match).isNotNull() + val key = KeyDecoder(match!!.args) + .decodeSerializableValue(match.serializer) + assertThat(key).isEqualTo(ProductDetail(productId = "abc123")) + } + + @Test + fun `invalid host is rejected`() { + val uri = "https://evil.com/products/abc123".toUri() + assertThat(DeepLinkValidator.validate(uri)).isFalse() + } + + @Test + fun `synthetic back stack includes all parents`() { + val key = ProductDetail(productId = "abc123") + val stack = buildSyntheticBackStack(key) + + assertThat(stack).containsExactly( + HomeRoute, + ProductListRoute, + ProductDetail(productId = "abc123") + ).inOrder() + } +} +``` + +#### Instrumented `onNewIntent` test + +Required: the deep-link `Activity` uses `android:launchMode="singleTask"` and forwards `onNewIntent` through the same parser as `onCreate` ([android-navigation.md → onNewIntent for singleTask](android-navigation.md#onnewintent-for-singletask)). + +Required: the destination composable root exposes `Modifier.testTag("…")` for every node the test asserts. + +Forbidden: launching a second `Activity` with `startActivity` to simulate a second link - `singleTask` reuses the instance; call `onNewIntent` on the running `Activity` only. + +```kotlin +// app/src/androidTest/kotlin/com/example/app/MainActivityDeepLinkTest.kt +import android.content.Intent +import android.net.Uri +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.example.app.MainActivity +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MainActivityDeepLinkTest { + + @get:Rule(order = 1) + val composeRule = createAndroidComposeRule() + + @Test + fun onNewIntentNavigatesToParsedDestination() { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com/products/deeplink-id")) + InstrumentationRegistry.getInstrumentation().runOnMainSync { + composeRule.activity.onNewIntent(intent) + } + composeRule.waitUntil(timeoutMillis = 5_000) { + composeRule.onAllNodesWithTag("product_detail_screen").fetchSemanticsNodes().isNotEmpty() + } + composeRule.onNodeWithTag("product_detail_screen").assertIsDisplayed() + } +} +``` + +Patterns, manifest, App Links, Dynamic App Links, security: [android-navigation.md](android-navigation.md#deep-links). + +## UI Tests + +### Compose UI Tests for Auth Screen with Truth + +```kotlin +// feature-auth/src/androidTest/kotlin/com/example/feature/auth/AuthScreenTest.kt +import com.google.common.truth.Truth.assertThat +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput + +class AuthScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun `login screen shows all required UI elements`() { + composeTestRule.setContent { + AppTheme { + LoginScreen( + uiState = AuthUiState.LoginForm(), + onAction = {}, + onRegisterClick = {}, + onForgotPasswordClick = {} + ) + } + } + + // Assert all UI elements are displayed + composeTestRule.onNodeWithText("Email").assertIsDisplayed() + composeTestRule.onNodeWithText("Password").assertIsDisplayed() + composeTestRule.onNodeWithText("Login").assertIsDisplayed() + composeTestRule.onNodeWithText("Create Account").assertIsDisplayed() + composeTestRule.onNodeWithText("Forgot Password?").assertIsDisplayed() + } + + @Test + fun `loading state shows progress indicator`() { + composeTestRule.setContent { + AppTheme { + LoginScreen( + uiState = AuthUiState.LoginForm(isLoading = true), + onAction = {}, + onRegisterClick = {}, + onForgotPasswordClick = {} + ) + } + } + + composeTestRule + .onNodeWithTag("loadingIndicator") + .assertIsDisplayed() + } + + @Test + fun `error state shows error message and retry button`() { + val errorMessage = "Invalid credentials" + + composeTestRule.setContent { + AppTheme { + LoginScreen( + uiState = AuthUiState.Error(errorMessage, canRetry = true), + onAction = {}, + onRegisterClick = {}, + onForgotPasswordClick = {} + ) + } + } + + // Assert error message is displayed + composeTestRule + .onNodeWithText(errorMessage) + .assertIsDisplayed() + + // Assert retry button is displayed + composeTestRule + .onNodeWithText("Retry") + .assertIsDisplayed() + } + + @Test + fun `user can input email and password`() { + composeTestRule.setContent { + AppTheme { + LoginScreen( + uiState = AuthUiState.LoginForm(), + onAction = {}, + onRegisterClick = {}, + onForgotPasswordClick = {} + ) + } + } + + // Input email + val email = "test@example.com" + composeTestRule + .onNodeWithText("Email") + .performTextInput(email) + + // Input password + val password = "password123" + composeTestRule + .onNodeWithText("Password") + .performTextInput(password) + + // Assert the inputs were captured (in real app, would verify ViewModel state) + // This test ensures UI components are interactive + } + + @Test + fun `clicking create account triggers callback`() { + var registerClicked = false + + composeTestRule.setContent { + AppTheme { + LoginScreen( + uiState = AuthUiState.LoginForm(), + onAction = {}, + onRegisterClick = { registerClicked = true }, + onForgotPasswordClick = {} + ) + } + } + + // Click create account + composeTestRule + .onNodeWithText("Create Account") + .performClick() + + // Assert callback was triggered + assertThat(registerClicked).isTrue() + } + + @Test + fun `clicking forgot password triggers callback`() { + var forgotPasswordClicked = false + + composeTestRule.setContent { + AppTheme { + LoginScreen( + uiState = AuthUiState.LoginForm(), + onAction = {}, + onRegisterClick = {}, + onForgotPasswordClick = { forgotPasswordClicked = true } + ) + } + } + + // Click forgot password + composeTestRule + .onNodeWithText("Forgot Password?") + .performClick() + + // Assert callback was triggered + assertThat(forgotPasswordClicked).isTrue() + } + + @Test + fun `login button is disabled when form is loading`() { + composeTestRule.setContent { + AppTheme { + LoginScreen( + uiState = AuthUiState.LoginForm(isLoading = true), + onAction = {}, + onRegisterClick = {}, + onForgotPasswordClick = {} + ) + } + } + + // Assert login button is disabled + composeTestRule + .onNodeWithText("Login") + .assertIsNotEnabled() + } +} + +``` + +### Compose tests v2 (Compose 1.11+) + +Required: write Compose UI tests against the v2 APIs. v1 APIs are deprecated. + +Behavior changes from v1: + +- Default Compose-internal dispatcher shifted from `UnconfinedTestDispatcher` to `StandardTestDispatcher`. +- Coroutines launched inside a composable queue until the virtual clock advances; eager execution no longer happens by default. + +Migration: tests that relied on eager execution may need `composeTestRule.mainClock.advanceTimeBy(...)` or `composeTestRule.waitForIdle()` to flush queued work before assertions. Follow the [Compose test v2 migration guide](https://developer.android.com/develop/ui/compose/testing/migrate-v2) for the full API mapping. + +Forbidden: opting back into the v1 dispatcher to hide a race condition. Fix the test or the production code. + +The general-coroutine guidance in [Coroutine Testing](#coroutine-testing) (which still defaults to `UnconfinedTestDispatcher` in `runTest` blocks for ViewModel and repository tests) is unchanged - the v2 default applies inside the Compose test framework itself. + +### Trackpad input tests (Compose 1.11+) + +Use [`performTrackpadInput`](https://developer.android.com/reference/kotlin/androidx/compose/ui/test/SemanticsNodeInteraction#\(androidx.compose.ui.test.SemanticsNodeInteraction\).performTrackpadInput\(kotlin.Function1\)) to drive trackpad pointer events in instrumentation tests. Pair with `performTouchInput`, `performMouseInput`, and `performKeyInput` so each gesture detector is covered across every pointer type the screen can receive. + +Required when a screen exposes: + +- `Modifier.scrollable` or `Modifier.transformable` reachable on tablet, foldable, Chromebook, or desktop form factors. +- Custom `pointerInput` gesture detectors that branch on drag, pinch, or two-finger swipe. + +Cross-reference: trackpad behavior change in [compose-patterns.md → Trackpad and mouse input](/references/compose-patterns.md#trackpad-and-mouse-input-compose-111). + +## Agent automation (ADB and UIAutomator) + +Commands and test shapes an **agent** proposes or runs **only when** a device or emulator is already attached, `adb` resolves, and the session allows shell access. Crash analysis and long `dumpsys`: [android-debugging.md](/references/android-debugging.md). Deep-link `am start` and `pm verify-app-links` matrices: [Testing Deep Links](#testing-deep-links). + +### Agent vs device + +| Action | Agent | Prerequisite | +|------------------------------------------------------|-------------------------------------------------|-----------------------------------------------------| +| Run `adb devices` and parse serial list | Yes, when shell runs | Device or emulator online | +| Build `adb -s SERIAL …` command lines for copy-paste | Yes | Correct `SERIAL` when multiple devices | +| Install APK the build produced | Yes, when file exists and `adb install` allowed | Artifact path valid; device unlocked if required | +| Author `androidTest` UIAutomator or Espresso smoke | Yes | `./gradlew connectedCheck` or CI emulator available | +| Instrumented test on real device without CI | No | Connected device and local Gradle or Studio run | + +Stop: never `adb install` over production user data without explicit user confirmation; never `pm clear` on a device the user did not identify as disposable. + +### Device targeting + +Required: when more than one line appears under `adb devices`, every mutating command uses `-s `. + +Forbidden: pick a serial not present in the latest `adb devices` output the session captured. + +```bash +adb devices -l +adb -s emulator-5554 install -r path/to/app-debug.apk +``` + +### Install, reset, launch + +```bash +adb -s SERIAL install -r app/build/outputs/apk/debug/app-debug.apk +adb -s SERIAL shell pm clear com.example.app +adb -s SERIAL shell am start -W -n com.example.app/.MainActivity +``` + +Use `am start -W` when the agent needs a deterministic return after cold start (timeout and exit code surface launch failures). + +Cold-start measurement stays in [android-performance.md → Macrobenchmark (Compose)](/references/android-performance.md#macrobenchmark-compose); keep ADB launch checks lightweight. + +### Logcat for smoke proof + +```bash +adb -s SERIAL logcat --pid=$(adb -s SERIAL shell pidof -s com.example.app) +adb -s SERIAL logcat -d -s AndroidRuntime:E | tail -n 80 +``` + +Use after install or `am start` to confirm absence of immediate process death; full crash triage stays in [android-debugging.md](/references/android-debugging.md). + +### UIAutomator v2 (instrumented smoke) + +Use when: black-box smoke across process boundaries, launcher widgets, or system UI; single-process Compose surfaces use `createComposeRule` as in [UI Tests](#ui-tests). + +Agent-allowed: add or edit a class under `src/androidTest/...` with `@RunWith(AndroidJUnit4::class)`, `InstrumentationRegistry.getInstrumentation()`, `UiDevice.getInstance(instrumentation)`, then `device.wait(Until.hasObject(By.pkg("com.example.app").depth(0)), 5000)` (replace package and timeout with real values). + +Minimal pattern (`src/androidTest/...`; replace `pkg` with the debug `applicationId`): + +```kotlin +import android.content.Intent +import androidx.core.content.ContextCompat +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SmokeLaunchTest { + @Test + fun coldStart_reachesPackageSurface() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val device = UiDevice.getInstance(instrumentation) + val context = instrumentation.targetContext + val pkg = "com.example.app" + val launchIntent = context.packageManager.getLaunchIntentForPackage(pkg) + ?: error("missing launch intent for $pkg") + launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + ContextCompat.startActivity(context, launchIntent, null) + device.wait(Until.hasObject(By.pkg(pkg).depth(0)), 10_000) + } +} +``` + +Dependencies: add `androidx.test.uiautomator:uiautomator` on `androidTestImplementation`; pin the version beside other AndroidX Test libraries in the catalog ([dependencies.md](/references/dependencies.md)). + +### When Compose test vs UIAutomator + +| Surface | Use | +|---------------------------------------------------|---------------------------------------------------------------------------------------------------------| +| Single-process Compose tree | `createComposeRule` + semantics in [UI Tests](#ui-tests) | +| Cross-app or hybrid View/Compose with stable `id` | UIAutomator `By.res` / `By.text` | +| Macrobenchmark / Baseline Profile collection | [android-performance.md](/references/android-performance.md) generator patterns with `UiAutomator` APIs | + +### CI wiring (reference only) + +Agent-allowed: add a workflow job that starts an emulator action (or uses the team's existing emulator service), runs `./gradlew :app:connectedDebugAndroidTest` with `ANDROID_SERIAL` set, uploads log artifacts on failure. + +### Further ADB + +Meminfo, `gfxinfo`, port forwarding, `run-as` listing: [android-debugging.md → ADB Quick Reference](/references/android-debugging.md#adb-quick-reference). + +## Pre-release UI state checklist + +Routing for auditing **screens and flows** before ship. Pair with [Screenshot Testing](#screenshot-testing) so each meaningful branch has a `@Preview` or screenshot test. + +### State routing + +| State or edge | Audit in code (agent) | Deep rules | +|----------------------------------------|-------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Empty first load | `UiState` branches, empty lists, placeholders | [compose-patterns.md → Loading and refresh UX](/references/compose-patterns.md#loading-and-refresh-ux) | +| Loading / pull-to-refresh | Skeleton vs full-screen replacement, stale-while-revalidate | [compose-patterns.md → Loading and refresh UX](/references/compose-patterns.md#loading-and-refresh-ux) | +| Recoverable error | Retry control, dismissible error surface | [compose-patterns.md](/references/compose-patterns.md), [kotlin-patterns.md](/references/kotlin-patterns.md) | +| Offline / no network path | Cached reads, queued writes, visible offline state | [android-data-sync.md → Offline-First Architecture](/references/android-data-sync.md#offline-first-architecture), [Network State Monitoring](/references/android-data-sync.md#network-state-monitoring) | +| Sync conflict in UI | User path to resolve or defer | [android-data-sync.md → Conflict Resolution](/references/android-data-sync.md#conflict-resolution) | +| Permission denied or settings required | Rationale, link to app settings where applicable | [android-permissions.md → Requesting Runtime Permissions in Compose](/references/android-permissions.md#requesting-runtime-permissions-in-compose) | +| Session expired / forced sign-out | Navigation to auth, cleared back stack | [architecture.md](/references/architecture.md) | +| RTL / long strings / density | Truncation, mirroring, overflow | [android-i18n.md](/references/android-i18n.md) | + +Stop: do not treat a screen as complete when only the success branch exists in Compose unless domain rules make other branches impossible; then document that exhaustively (for example sealed `when` with a comment or test proving exhaustiveness). + +## Screenshot Testing + +Required: use [Compose Preview Screenshot Testing](https://developer.android.com/studio/preview/compose-screenshot-testing) (host JVM, reuses `@Preview`). One test per meaningful state (loading, success, error, empty) for every key screen. + +### Preview Screenshot Testing vs Roborazzi + +| Approach | Use when | Avoid when | +|---------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------| +| Compose Preview Screenshot Testing (`screenshot` plugin, `screenshotTest` source set) | Layout and state fit a `@Preview` composable; CI without an emulator farm; fast diff of preview renders | Flow requires real navigation, gestures, or hybrid View surfaces previews cannot model | +| Roborazzi (Gradle-recorded bitmap diffs) | Need captures after `composeTestRule` / `AndroidComposeTestRule` interactions, Robolectric JVM runs, or full-screen bitmap compare | Team has not pinned Roborazzi coordinates and policy in the catalog yet - add explicit version entries before wiring | + +Required: pick one primary visual-regression stack per module family; do not duplicate the same golden coverage in Preview Screenshot and Roborazzi without ownership rules. + +Catalog pins for the Compose Preview screenshot plugin live in `assets/libs.versions.toml.template`; refresh those pins whenever Android Studio or AGP release notes change the supported plugin line. Roborazzi coordinates are not template-default - add them to the project catalog only when Roborazzi is adopted, using the versions the [Roborazzi project](https://github.com/takahirom/roborazzi) documents for the chosen setup. + +### Setup + +**1. `gradle.properties`:** +```properties +android.experimental.enableScreenshotTest=true +``` + +**2. Version catalog:** The `screenshot` version, `screenshot-validation-api` library, and `screenshot` plugin are defined in `assets/libs.versions.toml.template`. + +**3. Module `build.gradle.kts`:** +```kotlin +plugins { + alias(libs.plugins.screenshot) +} + +android { + experimentalProperties["android.experimental.enableScreenshotTest"] = true +} + +dependencies { + screenshotTestImplementation(libs.screenshot.validation.api) + screenshotTestImplementation(libs.androidx.compose.ui.tooling) +} +``` + +### Writing Screenshot Tests + +Place tests in the `screenshotTest` source set. Annotate with both `@PreviewTest` and `@Preview`: + +```kotlin +// app/src/screenshotTest/kotlin/com/example/app/LoginScreenScreenshotTest.kt +package com.example.app + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.android.tools.screenshot.PreviewTest +import com.example.app.ui.theme.AppTheme + +@PreviewTest +@Preview(showBackground = true) +@Composable +fun LoginScreen_Loading() { + AppTheme { + LoginScreen(uiState = LoginUiState.Loading, onAction = {}) + } +} + +@PreviewTest +@Preview(showBackground = true) +@Composable +fun LoginScreen_Success() { + AppTheme { + LoginScreen( + uiState = LoginUiState.LoginForm( + email = "user@example.com", + password = "password123" + ), + onAction = {} + ) + } +} + +@PreviewTest +@Preview(showBackground = true) +@Composable +fun LoginScreen_Error() { + AppTheme { + LoginScreen( + uiState = LoginUiState.Error("Invalid credentials", canRetry = true), + onAction = {} + ) + } +} +``` + +### Multi-Preview for Theme/Device Variants + +Use `@Preview` parameters or custom multi-preview annotations to test across configurations: + +```kotlin +@PreviewTest +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO, name = "Light") +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark") +@Composable +fun LoginScreen_Themes() { + AppTheme { + LoginScreen(uiState = LoginUiState.LoginForm(), onAction = {}) + } +} + +@PreviewTest +@Preview(showBackground = true, fontScale = 1.0f, name = "Default font") +@Preview(showBackground = true, fontScale = 1.5f, name = "Large font") +@Preview(showBackground = true, fontScale = 2.0f, name = "Largest font") +@Composable +fun LoginScreen_FontScales() { + AppTheme { + LoginScreen(uiState = LoginUiState.LoginForm(), onAction = {}) + } +} +``` + +### Configuring Image Difference Threshold + +```kotlin +// module build.gradle.kts +android { + testOptions { + screenshotTests { + imageDifferenceThreshold = 0.0001f // 0.01% tolerance + } + } +} +``` + +### Gradle Commands + +```bash +# Generate/update reference images (run once, then commit to VCS) +./gradlew updateDebugScreenshotTest + +# Update for a specific module +./gradlew :feature:auth:updateDebugScreenshotTest + +# Validate screenshots against references (run in CI) +./gradlew validateDebugScreenshotTest + +# Validate for a specific module +./gradlew :feature:auth:validateDebugScreenshotTest +``` + +Reference images: `{module}/src/screenshotTestDebug/reference/` - commit to VCS. Validation report: `{module}/build/reports/screenshotTest/preview/debug/index.html`. + +### Requirements + +- AGP 8.5+ (Gradle tasks); AGP 9.0+ for full IDE integration. +- JDK 17+. +- `com.android.compose.screenshot` plugin 0.0.1-alpha13+. + +### Rules + +Required: +- Wrap every preview in the app theme (`AppTheme { }`). +- Cover light and dark via `uiMode` or a multi-preview annotation. +- Cover at least one large `fontScale` to catch overflow. +- Keep tests in the `screenshotTest` source set; do not mix with unit or instrumented tests. +- Commit reference images alongside source. + +## Performance Benchmarks + +Use Macrobenchmark for end-to-end performance checks (startup, navigation, and Compose scrolling). +Setup and commands live in `references/android-performance.md`. + +## Test Utilities + +### Test Data Factories (in `core:testing`) + +```kotlin +// core/testing/src/main/kotlin/com/example/testing/data/TestData.kt +import com.google.common.truth.Truth.assertThat + +object TestData { + + // Auth test data + val testUser = User( + id = "user-123", + email = "test@example.com", + name = "Test User", + profileImage = null + ) + + val testAuthToken = AuthToken("token-123", testUser) + + fun createLoginForm( + email: String = "test@example.com", + password: String = "password123", + isLoading: Boolean = false, + emailError: String? = null, + passwordError: String? = null + ) = AuthUiState.LoginForm( + email = email, + password = password, + isLoading = isLoading, + emailError = emailError, + passwordError = passwordError + ) + + fun createRegisterForm( + email: String = "test@example.com", + password: String = "password123", + confirmPassword: String = "password123", + name: String = "Test User", + isLoading: Boolean = false, + errors: Map = emptyMap() + ) = AuthUiState.RegisterForm( + email = email, + password = password, + confirmPassword = confirmPassword, + name = name, + isLoading = isLoading, + errors = errors + ) + + fun createErrorState( + message: String = "Something went wrong", + canRetry: Boolean = true + ) = AuthUiState.Error(message, canRetry) + + // Network test data + val testNetworkUser = NetworkUser( + id = "user-123", + email = "test@example.com", + name = "Test User" + ) + + val testAuthTokenResponse = AuthTokenResponse( + token = "token-123", + user = testNetworkUser + ) + + // Entity test data + val testUserEntity = UserEntity( + id = "user-123", + email = "test@example.com", + name = "Test User" + ) + + // Test assertions + fun assertUserEquals(expected: User, actual: User) { + assertThat(actual.id).isEqualTo(expected.id) + assertThat(actual.email).isEqualTo(expected.email) + assertThat(actual.name).isEqualTo(expected.name) + assertThat(actual.profileImage).isEqualTo(expected.profileImage) + } + + fun assertAuthTokenEquals(expected: AuthToken, actual: AuthToken) { + assertThat(actual.value).isEqualTo(expected.value) + assertUserEquals(expected.user, actual.user) + } +} +``` + +### Running Tests + +```bash +# Run all unit tests +./gradlew test + +# Run tests for specific feature +./gradlew :feature:auth:test + +# Run instrumented tests +./gradlew connectedAndroidTest + +# Run tests with coverage +./gradlew testDebugUnitTestCoverage + +# Run specific test class +./gradlew :feature:auth:testDebugUnitTest --tests "*AuthViewModelTest" + +# Run tests with Truth assertions enabled +./gradlew test --info +``` + +## Rules + +Required: +- Use Google Truth (`assertThat(actual).isEqualTo(expected)`); never JUnit `assertEquals` / `assertTrue` / `assertNotNull`. +- Use Truth subject methods (`hasSize`, `contains`, `isInstanceOf`, `isNull`, `isNotNull`) instead of hand-rolled boolean assertions. +- Hand-written fakes mirror production behaviour with state and test hooks; never stub-only. +- Test each feature module's ViewModel and UI in isolation; never depend on another feature module from tests. +- Test `Navigator` interfaces with fakes; MockK only for Navigation 3 framework types in `app`. +- Use `@HiltAndroidTest` with a test-scoped `@Module` for DI tests. +- Use Room 3 in-memory builder with `setDriver(BundledSQLiteDriver())` for DAO tests; use `room3-testing` + `MigrationTestHelper` + `SQLiteConnection` for migration tests. +- Cover `SavedStateHandle` paths (navigation args + process-death restore). +- Use Turbine for any `Flow` that emits more than once. + +Forbidden: +- Mocking libraries in `feature:*` and `core:*` modules. +- Sharing test fixtures across feature modules (place them in `core:testing`). +- Relying on `Dispatchers.Main` directly; always use the project's `MainDispatcherRule`. + +## Paging 3 Testing + +### Testing ViewModels with PagingData + +When testing ViewModels that expose `PagingData`, use `PagingData.from()` to create test data: + +```kotlin +// core/testing/FakePagingRepository.kt +class FakeProductRepository : ProductRepository { + private val pagingFlow = MutableSharedFlow>(replay = 1) + + fun emitProducts(products: List) { + pagingFlow.tryEmit(PagingData.from(products)) + } + + fun emitError() { + // CORRECT: PagingData has no error channel — surface failures via Result or a parallel error Flow + pagingFlow.tryEmit(PagingData.empty()) + } + + override fun getProducts(query: String): Flow> = pagingFlow +} + +// feature/products/ProductsViewModelTest.kt +@Test +fun `when products loaded then state is success`() = runTest { + // Given + val testProducts = listOf( + Product(id = "1", name = "Product 1", price = 10.0), + Product(id = "2", name = "Product 2", price = 20.0) + ) + + // When + fakeRepository.emitProducts(testProducts) + advanceUntilIdle() + + // Then + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(ProductsUiState.Success::class.java) +} +``` + +### Important: `cachedIn()` Limitations + +**Warning:** `cachedIn(viewModelScope)` caches `PagingData` and can swallow exceptions, making error-state testing unreliable. + +```kotlin +// WRONG: Problematic for error testing +class ProductsViewModel @Inject constructor( + private val repository: ProductRepository +) : ViewModel() { + val products: Flow> = repository + .getProducts() + .cachedIn(viewModelScope) // Caches data, swallows some errors +} +``` + +**Solutions:** + +1. **Test error handling via non-paging use cases:** + ```kotlin + // For error scenarios, use a separate Result-based flow + val productsResult: StateFlow>> = repository + .getProductsAsList() + .catch { emit(Result.failure(it)) } + .stateIn(viewModelScope, SharingStarted.Lazily, Result.success(emptyList())) + + // Test error handling here instead + @Test + fun `when fetch fails then error state is shown`() = runTest { + fakeRepository.emitError(NetworkException()) + advanceUntilIdle() + + val result = viewModel.productsResult.value + assertThat(result.isFailure).isTrue() + } + ``` + +2. **Use separate error/loading states:** + ```kotlin + class ProductsViewModel @Inject constructor( + private val repository: ProductRepository + ) : ViewModel() { + private val _errorState = MutableStateFlow(null) + val errorState: StateFlow = _errorState.asStateFlow() + + val products: Flow> = repository + .getProducts() + .catch { error -> + _errorState.value = error.message + emit(PagingData.empty()) + } + .cachedIn(viewModelScope) + } + + @Test + fun `when fetch fails then error message is set`() = runTest { + fakeRepository.emitError() + advanceUntilIdle() + + assertThat(viewModel.errorState.value).isNotNull() + } + ``` + +### Testing with AsyncPagingDataDiffer + +For more advanced testing (checking actual loaded items), use `AsyncPagingDataDiffer`: + +```kotlin +@OptIn(ExperimentalCoroutinesApi::class) +@Test +fun `paging data contains expected items`() = runTest { + val testDispatcher = UnconfinedTestDispatcher(testScheduler) + + val differ = AsyncPagingDataDiffer( + diffCallback = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Product, newItem: Product) = + oldItem.id == newItem.id + override fun areContentsTheSame(oldItem: Product, newItem: Product) = + oldItem == newItem + }, + updateCallback = object : ListUpdateCallback { + override fun onInserted(position: Int, count: Int) {} + override fun onRemoved(position: Int, count: Int) {} + override fun onMoved(fromPosition: Int, toPosition: Int) {} + override fun onChanged(position: Int, count: Int, payload: Any?) {} + }, + workerDispatcher = testDispatcher + ) + + val testProducts = listOf( + Product(id = "1", name = "Product 1", price = 10.0), + Product(id = "2", name = "Product 2", price = 20.0) + ) + + fakeRepository.emitProducts(testProducts) + + viewModel.products.collect { pagingData -> + differ.submitData(pagingData) + } + + advanceUntilIdle() + + assertThat(differ.snapshot().items).hasSize(2) + assertThat(differ.snapshot().items[0].id).isEqualTo("1") + assertThat(differ.snapshot().items[1].id).isEqualTo("2") +} +``` + +### `paging-testing`: `asSnapshot` and `TestPager` + +Required: `testImplementation(libs.androidx.paging.testing)` and catalog rules in [dependencies.md](/references/dependencies.md#paging-3-test-artifact). Keep the `paging` version ref aligned with `paging-runtime` and `paging-compose`. + +Use `Flow>.asSnapshot { }` when the test drives the same `Flow` the UI collects and asserts the rendered item list after explicit loads, scrolls, or refresh. + +Inside the block the receiver is `SnapshotLoader`: call `scrollTo`, `refresh`, `appendScrollWhile`, `prependScrollWhile`, or `flingTo`, then return the snapshot list. `asSnapshot` is suspending; invoke it only from `runTest` (`kotlinx-coroutines-test`). + +```kotlin +import androidx.paging.testing.asSnapshot +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class ProductsPagingTest { + @Test + fun first_page_matches_repository() = runTest { + val items: List = viewModel.products.asSnapshot { + refresh() + } + assertEquals(2, items.size) + } +} +``` + +Use `TestPager` when the test targets a `PagingSource` in isolation (page keys, invalidation, error paths) without a ViewModel or `Flow` wrapper. [`androidx.paging.testing`](https://developer.android.com/reference/kotlin/androidx/paging/testing/package-summary) lists `TestPager` and related types. + +Use `PagingData.from()` / fakes when only static list-shaped `PagingData` is required. + +Use `AsyncPagingDataDiffer` when verifying diff callbacks and `ListUpdateCallback` behavior against submitted `PagingData`. + +### Paging rules + +Required: +- Use `PagingData.from(list)` for the common path. +- Hold error and loading state in a sibling `StateFlow`; do not assert errors through `PagingData` because `cachedIn` swallows them. +- Use `AsyncPagingDataDiffer` only when verifying actual loaded items. +- Use `asSnapshot` when asserting loaded content through the real `Flow>` pipeline. +- Use `TestPager` for direct `PagingSource` unit tests. +- `advanceUntilIdle()` before every assertion when the test mixes `runTest` with non-suspending collection patterns. + +Forbidden: +- Testing the Paging library's internal pagination logic. + +## Localization Testing + +See [android-i18n.md](/references/android-i18n.md#testing-localization) for locales, plurals, RTL, parameterized locale tests, RTL screenshots, and date/time/currency formatting. +- [Hilt Testing](https://developer.android.com/training/dependency-injection/hilt-testing) - Official Hilt testing guide diff --git a/DeviceMasker-main/.agents/skills/compose/compose-animations/SKILL.md b/DeviceMasker-main/.agents/skills/compose/compose-animations/SKILL.md new file mode 100644 index 000000000..c6b000bde --- /dev/null +++ b/DeviceMasker-main/.agents/skills/compose/compose-animations/SKILL.md @@ -0,0 +1,181 @@ +--- +name: compose-animations +description: "Use when writing or reviewing Jetpack Compose motion: visibility enter/exit, animating one property toward a target, color or size transitions, multiple properties from one state, switching composable content, or choosing between AnimatedVisibility, animate*AsState, rememberTransition, AnimatedContent, and Crossfade." +--- + +# Compose: animations + +Official reference: [Quick guide to Animations in Compose](https://developer.android.com/develop/ui/compose/animation/quick-guide). See also [Choose an animation API](https://developer.android.com/develop/ui/compose/animation/choose-api), [Value-based animations](https://developer.android.com/develop/ui/compose/animation/value-based), [Animation modifiers and composables](https://developer.android.com/develop/ui/compose/animation/composables-modifiers). + +## Core principle + +Pick the **smallest API that matches the problem**: built-in visibility and layout transitions first, then a single animated value, then a shared transition object when several values must move together, then gesture-level or imperative APIs when the framework cannot express the motion. + +## Pick the smallest animation API + +| Need | API | +|---|---| +| Show or hide a subtree with enter/exit semantics; content is removed after exit completes | [`AnimatedVisibility`](https://developer.android.com/develop/ui/compose/animation/composables-modifiers#animatedvisibility) | +| Animate one property toward a target derived from state | [`animateFloatAsState`](https://developer.android.com/develop/ui/compose/animation/value-based#animate-as-state) / `animateDpAsState` / `animateColorAsState` / `animateOffsetAsState` / … | +| Several animated values keyed off one boolean, enum, or sealed state | `rememberTransition` + transition child animations (`animateFloat`, `animateDp`, `animateColor`, `animateValue`, …) | +| Smooth size when child layout height/width changes (e.g. text wraps) | `Modifier.animateContentSize()` | +| Swap between different composable trees for the same slot | `AnimatedContent` or `Crossfade` | +| User-driven motion (drag, fling, interruptible springs) | [`Animatable`](https://developer.android.com/reference/kotlin/androidx/compose/animation/core/Animatable) and related coroutine APIs (see Advanced pointers) | + +## Appear and disappear + +**Prefer `AnimatedVisibility`** when the UI should leave or join the tree with enter/exit transitions. + +```kotlin +AnimatedVisibility(visible = expanded) { + Text("Details…") +} +``` + +**`animateFloatAsState` on alpha** only fades; the composable **stays in composition** and continues to participate in layout unless you gate it yourself. Use that tradeoff when you intentionally keep children mounted (state, focus) but visually hidden. For true remove-from-tree behavior, use `AnimatedVisibility` (or conditional composition with `AnimatedVisibility` / `AnimatedContent` patterns from the [quick guide](https://developer.android.com/develop/ui/compose/animation/quick-guide)). + +## Background color + +Use `animateColorAsState` for smooth color targets. + +For animated fills behind children, the [quick guide](https://developer.android.com/develop/ui/compose/animation/quick-guide) recommends drawing with **`Modifier.drawBehind`** rather than `Modifier.background()` so the animated color is applied in the draw phase appropriately for performance. + +```kotlin +val background = animateColorAsState( + targetValue = if (selected) selectedColor else idleColor, + label = "background", +) +Box( + Modifier.drawBehind { drawRect(background.value) }, +) { /* content */ } +``` + +## Size changes + +`Modifier.animateContentSize()` animates layout size changes—common for expanding/collapsing text or dynamic chips—without hand-rolling width/height animations. + +## Value-based animations (`animate*AsState`) + +Compose provides `animate*AsState` for `Float`, `Dp`, `Color`, `Size`, `Offset`, `Rect`, `Int`, `IntOffset`, `IntSize`, and more. You supply the **target**; the API owns the animation state. + +- Pass an [`AnimationSpec`](https://developer.android.com/reference/kotlin/androidx/compose/animation/core/AnimationSpec) via `animationSpec` (e.g. `spring`, `tween`) when defaults are wrong for the UI. +- Set a distinct **`label`** for debugging and tooling when multiple animations exist in one composable. +- For completion or sequencing details, see [Value-based animations](https://developer.android.com/develop/ui/compose/animation/value-based). + +```kotlin +val width by animateDpAsState( + targetValue = if (expanded) 200.dp else 56.dp, + animationSpec = spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMedium), + label = "fabWidth", +) +``` + +## Multiple properties: `rememberTransition` + +When one piece of state (e.g. `enum class Phase { A, B, C }`) should drive **several** animated values in lockstep, use `rememberTransition` and define child animations on that transition: + +```kotlin +val transition = rememberTransition(targetState = phase, label = "phase") +val alpha by transition.animateFloat(label = "alpha") { target -> + if (target == Phase.Visible) 1f else 0f +} +val offset by transition.animateDp(label = "offset") { target -> + if (target == Phase.Visible) 0.dp else 24.dp +} +``` + +Avoid multiple independent `animate*AsState` calls that should stay visually synchronized but can drift if specs or targets diverge. Older code may use `updateTransition`; prefer `rememberTransition` for new code. + +## Choosing between content-level APIs + +Use the official [Choose an animation API](https://developer.android.com/develop/ui/compose/animation/choose-api) tree when unsure. Compressed rules: + +| Situation | Prefer | +|---|---| +| Same composable, different **target values** for layout properties | `animate*AsState` or `rememberTransition` | +| Different **composable content** for the same region (tabs, steps) | `AnimatedContent` (custom `transitionSpec`, `contentKey`) or simpler `Crossfade` | +| Pager-like **swipe between pages** | Horizontal pager APIs from the animation docs / Material—follow the choose-api guidance | +| Transitions **owned by Navigation Compose** | Use navigation’s built-in transitions rather than bolting `AnimatedContent` on top of the same destination swap | + +**Art-based motion** (illustrations, Lottie, complex vector timelines) is outside this skill; use dedicated libraries and the “additional resources” links on [Animations](https://developer.android.com/develop/ui/compose/animation/resources). + +## Decision flow (high level) + +```mermaid +flowchart TD + start[Animation_need] + start --> showHide{Show_or_hide_subtree} + showHide -->|yes| av[AnimatedVisibility] + showHide -->|no| oneProp{Single_property_to_target} + oneProp -->|yes| asState["animate*AsState"] + oneProp -->|no| multiProp{Many_props_one_state} + multiProp -->|yes| rt[rememberTransition] + multiProp -->|no| swapTree{Different_composable_content} + swapTree -->|yes| ac[AnimatedContent_or_Crossfade] + swapTree -->|no| advanced[Animatable_or_lower_level] +``` + +## AnimatedContent keys for state holders + +When `AnimatedContent` receives a state-holder wrapper such as `AsyncResult`, `Result`, or a sealed `UiState`, decide what should actually trigger the transition. Usually the animation should run when the **content shape** changes (loading → content → error), not when the payload inside the same shape changes. + +Use `contentKey` to map rich state to the animation identity: + +```kotlin +AnimatedContent( + targetState = result, + contentKey = { state -> + when (state) { + AsyncResult.Loading -> "loading" + is AsyncResult.Success -> "content" + is AsyncResult.Error -> "error" + } + }, + label = "profile-content", +) { state -> + when (state) { + AsyncResult.Loading -> Loading() + is AsyncResult.Success -> Profile(state.value) + is AsyncResult.Error -> ErrorMessage(state.throwable) + } +} +``` + +Without `contentKey`, every unequal `Success(value)` can be treated as new content. That is useful if a payload change should animate, but noisy when fresh data updates the same screen shape. + +Choose keys by visual shape: + +| State change | Typical `contentKey` | +|---|---| +| Loading → Success → Error | Branch key: `"loading"`, `"content"`, `"error"` | +| Success item A → Success item B should crossfade | Stable item id | +| Success data refresh should update in place | Constant content key for `Success` | +| Error message text changes but error UI shape stays | Constant content key for `Error` | + +## Animated values and composition performance + +`animate*AsState` returns `State` that updates frequently. If that value feeds `Modifier.offset`, `Modifier.graphicsLayer`, scroll-adjacent layout, or other **frame-rate** paths, avoid reading it in the composable body with `by` and then passing it into value-form modifiers—use **deferred reads** (block modifiers, draw/ layout lambdas) instead. See [`compose-state-deferred-reads`](../compose-state-deferred-reads/SKILL.md). + +If recomposition counters spike during motion unrelated to bad stability, see [`compose-recomposition-performance`](../compose-recomposition-performance/SKILL.md). + +## Advanced pointers (read the linked docs) + +- **Gesture-driven or cancelable motion**: [`Animatable`](https://developer.android.com/reference/kotlin/androidx/compose/animation/core/Animatable) with `snapTo`, decay, and `pointerInput`—[Advanced animation example: Gestures](https://developer.android.com/develop/ui/compose/animation/advanced) and [Drag, swipe, and fling](https://developer.android.com/develop/ui/compose/touch-input/pointer-input/drag-swipe-fling). +- **Infinite or repeating cycles**: [`rememberInfiniteTransition`](https://developer.android.com/reference/kotlin/androidx/compose/animation/core/rememberInfiniteTransition). +- **Seekable / test-controlled progress**: [`SeekableTransitionState`](https://developer.android.com/reference/kotlin/androidx/compose/animation/core/SeekableTransitionState) and related APIs for predictable timelines in tests or tooling. + +## Common mistakes + +| Mistake | Fix | +|---|---| +| Fade with `animateFloatAsState(alpha)` but expect children to unmount | Use `AnimatedVisibility` or remove the subtree from composition when hidden | +| Three `animateDpAsState` calls that must stay in sync with one enum | One `rememberTransition` + child animations | +| Animated color on `Modifier.background` causing extra work | Prefer `drawBehind { drawRect(animatedColor) }` per quick guide | +| Chaining `LaunchedEffect` + manual `Animatable` for simple target animation | Prefer `animate*AsState` or `rememberTransition` unless gestures require `Animatable` | +| Ignoring Navigation’s own transitions | Use Nav APIs for destination transitions; do not duplicate with `AnimatedContent` for the same swap | +| `AnimatedContent(targetState = asyncResult)` animates on every data refresh | Add `contentKey` based on the visual shape or stable item identity | + +## When not to use this skill + +- **Side-effect timing** (`LaunchedEffect`, clicks launching work): use [`compose-side-effects`](../compose-side-effects/SKILL.md). +- **Deep performance tuning** of where snapshot state is read: use [`compose-state-deferred-reads`](../compose-state-deferred-reads/SKILL.md) as the primary reference. diff --git a/DeviceMasker-main/.agents/skills/compose/compose-focus-navigation/SKILL.md b/DeviceMasker-main/.agents/skills/compose/compose-focus-navigation/SKILL.md new file mode 100644 index 000000000..080ecf83d --- /dev/null +++ b/DeviceMasker-main/.agents/skills/compose/compose-focus-navigation/SKILL.md @@ -0,0 +1,149 @@ +--- +name: compose-focus-navigation +description: Use when writing or reviewing Jetpack Compose UI for TV, keyboard, desktop, accessibility focus, D-pad navigation, FocusRequester, focusProperties, key events, or initial focus behavior. +--- + +# Compose: focus navigation + +## Core principle + +Focus is stateful UI behavior. Make focus targets explicit, request focus after composition succeeds, and test navigation with the same input model users use: keyboard, D-pad, or remote keys. + +## When to use this skill + +Use this when UI: + +- Runs on TV, desktop, ChromeOS, keyboard-first Android, or remote-control devices. +- Uses `FocusRequester`, `focusRequester`, `focusProperties`, `onFocusChanged`, or key handlers. +- Needs initial focus, restored focus, directional navigation, or back/escape behavior. +- Has a carousel, grid, lazy list, menu, dialog, or modal with focus traps. +- Has tests asserting which item is focused. + +## Build focus targets deliberately + +Start with components that already participate in focus, then add only the focus hooks the behavior needs: + +| Need | Add | +|---|---| +| Normal button/text field/clickable focus | Nothing extra; use the focusable component | +| Programmatic initial/restored focus | `FocusRequester` + `Modifier.focusRequester(...)` | +| Visual or state reaction to focus changes | `Modifier.onFocusChanged { ... }` | +| Custom interactive surface that is not already focusable | `Modifier.focusable()` plus role/semantics as appropriate | + +For example, request and observe focus only when both behaviors are needed: + +```kotlin +val requester = remember { FocusRequester() } + +Button( + onClick = onClick, + modifier = Modifier + .focusRequester(requester) + .onFocusChanged { state -> isFocused = state.isFocused }, +) { + Text("Play") +} +``` + +Prefer focusable components (`Button`, `TextField`, clickable/selectable surfaces) over manually adding `focusable()` to passive layout. Add manual focus only when the element is truly interactive or participates in navigation. + +## Request focus after composition + +Call focus requests from an effect, not from the composable body: + +```kotlin +val initialFocus = remember { FocusRequester() } + +LaunchedEffect(initialFocus) { + initialFocus.requestFocus() +} +``` + +If the target appears after loading, key the request to the condition: + +```kotlin +LaunchedEffect(items.isNotEmpty()) { + if (items.isNotEmpty()) { + firstItemRequester.requestFocus() + } +} +``` + +For lazy content, request focus only after the item is actually composed. Keep requesters in stable item state keyed by item id, not by index alone if the list can reorder. + +## Directional navigation + +Use `focusProperties` when default spatial search is wrong: + +```kotlin +Modifier.focusProperties { + up = headerRequester + down = firstRowRequester + left = FocusRequester.Cancel +} +``` + +Use this sparingly. Too many hard-coded links create stale focus graphs when layouts change. Prefer natural focus order unless the design requires a specific jump or trap. + +## Key events + +Use key handlers for behavior that is not normal click/focus traversal: + +```kotlin +Modifier.onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyUp && event.key == Key.Back) { + onBack() + true + } else { + false + } +} +``` + +Return `true` only when consumed. Returning `true` too broadly breaks text entry, accessibility shortcuts, and parent navigation. + +For rapid D-pad input, throttle at the boundary that owns the expensive behavior (for example row scrolling or paging), not globally across the whole screen. + +## Focus restoration + +Preserve focus by semantic identity: + +- Track selected/focused item id, not just index. +- Use stable `key` values in lazy lists and grids. +- When content refreshes, re-request focus for the same id if it still exists. +- If it no longer exists, choose a deterministic fallback: nearest neighbor, first item, or parent container. + +## Common mistakes + +| Mistake | Fix | +|---|---| +| Adding `focusRequester` and `onFocusChanged` to every button | Add them only when requesting or observing focus | +| `requestFocus()` in the composable body | Move to `LaunchedEffect` | +| Initial focus keyed to `Unit` while target appears later | Key to loaded/visible condition | +| Focus requesters stored by lazy list index | Store by stable item id | +| Everything gets custom `focusProperties` | Let spatial search work; override only broken edges | +| Key handler returns `true` for all keys | Consume only handled keys | +| Tests click nodes in TV/D-pad UI | Send key input and assert focus | + +## Testing + +Test focus through user input: + +```kotlin +composeTestRule.onNodeWithTag("screen").performKeyInput { + pressKey(Key.DirectionDown) +} + +composeTestRule.onNodeWithTag("play-button").assertIsFocused() +``` + +Prefer asserting focused semantics over visual styling. Use screenshot tests only for focus appearance, not for deterministic focus ownership. + +Broader test-shape choices (plain UI vs integration, semantics-first): [`compose-ui-testing-patterns`](../compose-ui-testing-patterns/SKILL.md). + +## Red flags during review + +- "It focuses correctly when I tap it" for a keyboard/TV UI. +- Initial focus works only with fixed data and fails after loading/refresh. +- Focus state is inferred from selected data state when focus and selection are different concepts. +- The focus graph is described in comments but not encoded or tested. diff --git a/DeviceMasker-main/.agents/skills/compose/compose-modifier-and-layout-style/SKILL.md b/DeviceMasker-main/.agents/skills/compose/compose-modifier-and-layout-style/SKILL.md new file mode 100644 index 000000000..82a6ea159 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/compose/compose-modifier-and-layout-style/SKILL.md @@ -0,0 +1,333 @@ +--- +name: compose-modifier-and-layout-style +description: Use when writing or reviewing Jetpack Compose layout APIs, modifier parameters, modifier chain construction, hardcoded root layout decisions, or layout wrappers around a single conditional. +--- + +# Compose modifier and layout style + +## Core principle + +A composable that emits layout is a leaf the *parent* places — the parent decides position, size, alignment, padding. The composable's job is structure (what's inside), not placement (where it goes). Three rules follow: + +- **Declare a `modifier` parameter and apply it to the root**, so the parent can actually do its job. Hardcoding `.fillMaxWidth()` on a composable's root takes that decision away from every future caller. +- **Construct modifier chains as one fluent expression**, not stepwise reassignments. Both compile to the same thing, but the chain *reads* as intent in one pass. +- **Conditional rendering belongs where the condition applies.** A layout call whose only content is one `if` exists solely to hold the condition — push the `if` outside instead. + +These travel together because the same composable usually triggers all three: you declare its parameters (rule 1), the caller constructs a chain to position it (rules 2), and the body has a conditional you might be tempted to wrap (rule 3). + +## When to use this skill + +- You're writing a `@Composable fun` that calls a layout (`Box`, `Column`, `Row`, `LazyColumn`, `Text`, `Image`, `Surface`, `Card`, `Layout { … }`, anything from `compose.foundation.layout` or `compose.material*`) and its signature has no `modifier` parameter, or has one that isn't applied to the root, or has a hardcoded `.fillMaxWidth()`/`.padding(...)` on the root. +- You see `var m = Modifier` followed by `m = m.padding(…)`, `m = m.background(…)`, etc. +- A `modifier = …` argument has three or more chained calls on a single line. +- A composable's body is `Layout { if (cond) Content() }` — one conditional, nothing else. + +## 1. Declare a `modifier` parameter + +For composables that emit layout, prefer a `modifier` parameter after required parameters and before content/lambda parameters, with a default of `Modifier`. The name is exactly `modifier` — not `mod`, not `m`, not `wrapperModifier`. + +```kotlin +// ❌ BAD — no modifier param; caller can't position, size, or constrain this +@Composable +fun HomeScreenHeader(title: String, subtitle: String) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(title, style = MaterialTheme.typography.headlineLarge) + Text(subtitle, style = MaterialTheme.typography.bodyMedium) + } +} +``` + +```kotlin +// ✅ GOOD — parent decides width and padding; the composable describes structure only +@Composable +fun HomeScreenHeader( + title: String, + subtitle: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(title, style = MaterialTheme.typography.headlineLarge) + Text(subtitle, style = MaterialTheme.typography.bodyMedium) + } +} +``` + +The caller now writes `HomeScreenHeader(title, subtitle, Modifier.fillMaxWidth().padding(horizontal = 16.dp))` once, at the home screen — the only place that knows the layout actually wants those. + +## 2. Apply the caller's modifier to the root, and apply it first + +When the root layout already takes other arguments (alignment, arrangement, padding *that's intrinsic to the composable*), the caller-provided modifier still goes on the root layout's `modifier` parameter — and the composable's local chain is appended after. + +```kotlin +// ❌ BAD — modifier accepted but never applied +@Composable +fun Avatar(url: String, modifier: Modifier = Modifier) { + Image(painter = rememberAsyncImagePainter(url), contentDescription = null) +} + +// ❌ BAD — applied to a child, not the root; caller's size/position changes don't take +@Composable +fun Avatar(url: String, modifier: Modifier = Modifier) { + Box { + Image( + painter = rememberAsyncImagePainter(url), + contentDescription = null, + modifier = modifier, + ) + } +} + +// ❌ BAD — caller's modifier ends up last, so the composable's own size wins +@Composable +fun Avatar(url: String, modifier: Modifier = Modifier) { + Image( + painter = rememberAsyncImagePainter(url), + contentDescription = null, + modifier = Modifier + .clip(CircleShape) + .size(48.dp) + .then(modifier), + ) +} +``` + +```kotlin +// ✅ GOOD — caller's modifier first, then the composable's intrinsic chain +@Composable +fun Avatar(url: String, modifier: Modifier = Modifier) { + Image( + painter = rememberAsyncImagePainter(url), + contentDescription = null, + modifier = modifier + .clip(CircleShape) + .size(48.dp), + ) +} +``` + +Order matters: in a modifier chain, the *earlier* segment is the outer wrapper. The caller's modifier should be the outermost so caller-provided `.size(...)` or `.padding(...)` can override the composable's defaults rather than being overridden by them. + +## 3. Don't hardcode layout decisions on the root + +If the composable's root has `.fillMaxWidth()`, `.padding(horizontal = 16.dp)`, `.height(56.dp)`, etc., the caller can't *not* have them. Those are layout choices the parent should own. + +```kotlin +// ❌ BAD — every caller now fills max width whether they want to or not +@Composable +fun PrimaryButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) { + Button( + onClick = onClick, + modifier = modifier.fillMaxWidth(), // ← hardcoded + ) { Text(text) } +} + +// ✅ GOOD — caller adds .fillMaxWidth() if (and only if) they want it +@Composable +fun PrimaryButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) { + Button(onClick = onClick, modifier = modifier) { Text(text) } +} +``` + +The carve-out is for modifiers that are part of the **identity** of the composable — what makes an `Avatar` an avatar (the `.clip(CircleShape)` and a default `.size(48.dp)`), not where it sits on the screen. Test: can you imagine a caller wanting a version of this composable *without* that modifier? If yes, push it out. If no (an avatar without `clip(CircleShape)` isn't an avatar), keep it — but put it *after* the caller's modifier in the chain (see §2). + +## 4. Construct modifier chains as one fluent expression + +Recomposition re-runs the composable body — every modifier expression is re-evaluated. Reassigning `var modifier =` step-by-step looks plausible but breaks the visual flow, invites further mutation, and produces nothing a chain doesn't. + +```kotlin +// ❌ BAD — visual flow broken into reassignments; `var` invites more mutation +@Composable +fun Demo() { + var m = Modifier + m = m.padding(16.dp) + m = m.fillMaxSize() + Box(m) { } +} + +// ❌ ALSO BAD — same shape, dressed up with .then() +@Composable +fun Demo() { + var m = Modifier + m = m.padding(16.dp) + m = m.then(Modifier.fillMaxSize()) + Box(m) { } +} +``` + +```kotlin +// ✅ GOOD +@Composable +fun Demo() { + val m = Modifier + .padding(16.dp) + .fillMaxSize() + Box(m) { } +} +``` + +`val`, not `var`: once the chain is built, nothing should re-bind it. The reassignment shape is what makes `var` look necessary; the chain shape doesn't need it. + +### Inline at the call site is fine for short chains + +For one or two calls, build the modifier inline. The "extract to a `val`" rule only earns its keep when the chain is long enough to be worth naming, or when the same chain repeats. + +```kotlin +// ✅ GOOD — short chain inline +Box(modifier = Modifier.fillMaxWidth()) { … } +Box(modifier = Modifier.padding(8.dp).background(Color.Red)) { … } +``` + +### Conditional segments stay on the chain + +A common reason to reach for `var` is "the modifier depends on a condition." It doesn't — splice the condition inline: + +```kotlin +// ✅ GOOD — conditional inside the chain, still one expression +Box( + modifier = Modifier + .fillMaxWidth() + .then(if (selected) Modifier.background(Color.Red) else Modifier), +) +``` + +`Modifier` (the empty modifier) is the identity element for `.then` — it lets you keep the chain shape when one branch contributes nothing. + +## 5. Multiline formatting at the call site + +When a `modifier` argument's chain has **three or more** calls, format multiline with one call per line. Indent the chain so the dotted calls align beneath the value. + +```kotlin +// ❌ BAD — three+ calls on one line; hard to scan +Box( + modifier = modifier.fillMaxSize().padding(16.dp).weight(1f), +) + +// ✅ GOOD +Box( + modifier = modifier + .fillMaxSize() + .padding(16.dp) + .weight(1f), +) +``` + +One or two calls stay on a single line — the threshold is the call count, not the character count. If a single call has very long arguments, that's a different problem (extract a `val`, or shorten the arguments). + +This applies *only* to a parameter named `modifier`. Other fluent-style arguments aren't covered here. + +## 6. Hoist single conditionals out of the layout + +When a layout's *only* content is one `if`, the layout exists solely to "hold" the conditional. Move the `if` outside — the layout will only exist when it has something to show. + +```kotlin +// ❌ BAD — Column always emitted; only its inner content is conditional +@Composable +fun A() { + Column { + if (showHeader) { + Text("Title") + Text("Subtitle") + } + } +} + +// ✅ GOOD — Column only exists when it has content +@Composable +fun A() { + if (showHeader) { + Column { + Text("Title") + Text("Subtitle") + } + } +} +``` + +The benefit isn't a performance win — the runtime handles both fine — it's that the second form *reads* as "header section, conditionally." The first reads as "always-on column that may or may not have content." + +### The carve-outs (and why) + +- **Layout carries visual semantics that aren't conditional.** When the layout call passes `modifier`, `contentAlignment`, `horizontalArrangement`, or `verticalAlignment`, those arguments describe the *container*, not the content. Hoisting the conditional either loses those (the container collapses with the content) or duplicates them into both branches. Leave it. + + ```kotlin + // ✅ KEEP AS-IS — modifier on the container is doing visible work + @Composable + fun A(modifier: Modifier = Modifier) { + Box(modifier = modifier) { + if (something) { + Text("Bleh1") + Text("Bleh2") + } + } + } + ``` + +- **There are siblings to the `if`.** The layout has other content; the `if` is just one piece. Hoisting either pulls the siblings out (changing the layout) or leaves a different shape behind. Leave it. + +- **`if … else …` with both branches contributing composables.** Both branches do work; nothing to hoist; the layout *is* the shared container. + + ```kotlin + // ✅ KEEP AS-IS — both branches contribute to the layout + Box { + if (something) Text("Hint") else innerTextField() + } + ``` + +## Quick reference + +| Symptom | Diagnosis | Fix | +|---|---|---| +| `@Composable fun Foo(text: String)` with `Column`/`Box`/`Text` in body | No `modifier` param (§1) | Add `modifier: Modifier = Modifier`; pass to root | +| `modifier: Modifier = Modifier` declared but never referenced | Param ignored (§2) | Apply to root layout's `modifier` arg | +| `modifier` passed to a child, not the root | Wrong target (§2) | Move to the outermost layout's `modifier` | +| `modifier = Modifier.x().y().then(modifier)` | Caller's modifier last (§2) | Reorder: `modifier = modifier.x().y()` | +| `modifier = modifier.fillMaxWidth().padding(...)` on a general-purpose component | Layout hardcoded (§3) | Remove the hardcoded calls; let callers add them | +| Sibling composables in the file don't have `modifier` either | Spreading anti-pattern | Fix this one; fix siblings opportunistically | +| `mod: Modifier = Modifier` or `wrapperModifier: Modifier = Modifier` | Wrong name (§1) | Rename to exactly `modifier` | +| `var m = Modifier` followed by `m = m.xxx()` reassignments | Stepwise modifier construction (§4) | One fluent chain on a `val`, or build inline | +| `var m = Modifier; m = m.then(Modifier.xxx())` | Same shape via `.then` (§4) | Collapse `.then(Modifier.x())` to `.x()` in the chain | +| Modifier branch needs a condition | Reaching for `var` (§4) | `.then(if (c) Modifier.x() else Modifier)` inside the chain | +| `modifier = modifier.a().b().c()` on one line | Long chain not formatted (§5) | One call per line, indented under the value | +| `Layout { if (cond) X() }` with no other content and no layout-tuning args | Hoist (§6) | Move the `if` outside the layout | +| `Box(modifier = …) { if (cond) X() }` | Layout carries semantics — leave (§6 carve-out) | Keep as-is | +| `Box { if (cond) X() else Y() }` | Both branches contribute — leave (§6 carve-out) | Keep as-is | + +## When NOT to apply + +- **Composables that don't emit layout.** A `@Composable fun computeColor(): Color` or a `@Composable @ReadOnlyComposable` accessor doesn't emit a layout node. No `modifier` parameter needed (and a `@ReadOnlyComposable` couldn't accept one — see `compose-state-authoring`). +- **`@Preview` functions.** Previews are throwaway entry points; the framework calls them with no caller. A `modifier` parameter would be unused dead weight. +- **Test-only composables** inside `*Test` sources whose only caller is `composeTestRule.setContent { … }`. Same reasoning as previews. +- **Internal layout primitives that take a `modifier` as their *first required* parameter** (very rare; framework-level). The rule is "first *optional* param"; some private utilities legitimately have `modifier` upfront as required. +- **Modifier assembled imperatively from animation state.** A modifier built by appending values from `Animatable` or other procedural sources may legitimately need intermediate variables. The chain isn't the goal; readability is. If the chain becomes a worse expression, write the imperative form. +- **Slot APIs that store modifiers** in a data class or builder (rare; usually framework-level code). The fluent-chain idea is about user-site construction. +- **Test composables** pinning specific recomposition shapes — usually fine either way; don't refactor test composables purely for style. + +The declaration-side rules (§1–§3) should not be skipped merely because "this composable is internal", "only used in one place", "I'd rather not have the extra parameter on the signature", or "we know all the callers already". Those are exactly the rationalisations that produce composables that become single-use the day someone wants to call them twice. + +## Red flags during review + +| Thought | Reality | +|---|---| +| "This composable is internal-only — adding `modifier` is over-engineering" | The parameter is eight characters and a default. It's not over-engineering; it's the convention. Skipping it is the over-engineering — it's a custom decision against the grain of every Compose API. | +| "It's only used in one place, so I know the layout requirements" | "Only used in one place" describes today. The cost of the parameter is paid once; the cost of refactoring callers when the second use site appears is paid per caller. | +| "The sibling composables in this file don't have `modifier` either, so I'm matching style" | Spreading an anti-pattern isn't matching style. Fix this one. Fix the siblings opportunistically. | +| "The parent always wants `.fillMaxWidth()` here" | Then the parent passes `.fillMaxWidth()`. The composable doesn't decide that for callers it hasn't met yet. | +| "I'll add it when someone needs it" | You're someone. You need it now (for the convention). The next caller won't add it either — they'll work around its absence. | +| "It's a tiny composable — the modifier param is noise" | The param is eight characters at the declaration and zero characters at any call site that doesn't need it. The "noise" is imagined. | +| "I added `modifier` but kept `.fillMaxWidth()` on the root so the home screen doesn't have to" | Then the *not*-home-screen caller can't unset it. Move the `.fillMaxWidth()` to the caller. | +| "I need `var` for the modifier because the chain depends on a condition" | A conditional segment is `.then(if (c) Modifier.x() else Modifier)`, still on one chain. No `var` needed. | +| "Three lines is too few to make multiline" | Three chained calls *is* the threshold. Below three, one line. At or above three, multiline. | +| "The Column adds nothing but I'll keep it for symmetry" | Then hoist the conditional and keep the Column inside the consequent — symmetry preserved, no always-on container. | +| "I'll put the `if` inside because the layout already exists" | "Already exists" is the bug. The layout shouldn't exist when the condition is false. | + +## Related + +- [`compose-slot-api-pattern`](../compose-slot-api-pattern/SKILL.md) — the other half of declaring a reusable composable's public API: take `@Composable () -> Unit` slots for variable content. A reusable component takes both a `modifier` parameter *and* slots — caller owns placement *and* what to place. diff --git a/DeviceMasker-main/.agents/skills/compose/compose-recomposition-performance/SKILL.md b/DeviceMasker-main/.agents/skills/compose/compose-recomposition-performance/SKILL.md new file mode 100644 index 000000000..717f68f7f --- /dev/null +++ b/DeviceMasker-main/.agents/skills/compose/compose-recomposition-performance/SKILL.md @@ -0,0 +1,34 @@ +--- +name: compose-recomposition-performance +description: Use when investigating Jetpack Compose recomposition performance, skippable/restartable composables, composables.txt or compiler reports, Layout Inspector recomposition counts, or frame-rate State reads in composition vs layout/draw, and it is not yet clear whether the cause is parameter stability or deferred reads. +--- + +# Compose recomposition performance + +Router only — deep fixes live in [`compose-stability-diagnostics`](../compose-stability-diagnostics/SKILL.md) and [`compose-state-deferred-reads`](../compose-state-deferred-reads/SKILL.md). + +## Two axes + +1. **Parameter stability / skipping** — can Compose skip this restartable composable; are arguments stable and comparable? +2. **Where `State` is read** — is frame-rate `State` read during composition vs layout/draw? + +Either axis can dominate; they combine independently. + +## Route here → focused skill + +| Primary suspicion | Next skill | +|---|---| +| Skipping, unstable params, compiler/`composables.txt` churn | [`compose-stability-diagnostics`](../compose-stability-diagnostics/SKILL.md) | +| Frame-rate `State` read phase (composition vs layout/draw) | [`compose-state-deferred-reads`](../compose-state-deferred-reads/SKILL.md) | +| Evidence for both | Apply both skills in parallel | + +## Review order + +1. Decide which axis fits the evidence; open the matching skill. +2. If unclear, sample both — stability churn vs composition-phase reads of fast `State`. +3. Re-measure after changes. + +## When NOT to apply + +- Recomposition tracks real data changes, or the bug is correctness not cost. +- No profiler / compiler signal suggests a problem. diff --git a/DeviceMasker-main/.agents/skills/compose/compose-side-effects/SKILL.md b/DeviceMasker-main/.agents/skills/compose/compose-side-effects/SKILL.md new file mode 100644 index 000000000..aa20ecd4a --- /dev/null +++ b/DeviceMasker-main/.agents/skills/compose/compose-side-effects/SKILL.md @@ -0,0 +1,173 @@ +--- +name: compose-side-effects +description: Use when writing or reviewing Jetpack Compose code with LaunchedEffect, DisposableEffect, SideEffect, rememberCoroutineScope, rememberUpdatedState, snapshotFlow, snackbar, navigation, focus requests, analytics, or event Flow collection. +--- + +# Compose: side effects + +## Core principle + +Composable bodies describe UI. They can be recomposed, skipped, or abandoned. Work that changes the outside world belongs in an effect API whose lifecycle matches the work. + +## Pick the smallest effect + +| Need | API | +|---|---| +| Publish Compose state to non-Compose code after every successful recomposition | `SideEffect` | +| Register/unregister a listener, callback, observer, or resource | `DisposableEffect(keys...)` | +| Run suspending, deferred, or keyed one-shot work | `LaunchedEffect(keys...)` | +| Launch suspending work from a user event callback | `rememberCoroutineScope()` | +| Convert Compose snapshot reads into a Flow inside a coroutine | `snapshotFlow { ... }` inside `LaunchedEffect` | + +## Effect keys + +Keys define restart identity. When any key changes, the old effect is cancelled/disposed and a new one starts. + +```kotlin +// ✅ Restart collection when userId changes +LaunchedEffect(userId) { + repository.events(userId).collect { event -> handle(event) } +} + +// ❌ Unit hides a changing input; collection keeps using the first userId +LaunchedEffect(Unit) { + repository.events(userId).collect { event -> handle(event) } +} +``` + +Use stable, semantic keys: + +- Use the thing whose lifecycle the effect follows: `userId`, `screenId`, `lifecycleOwner`, `focusRequester`. +- Do not use broad objects (`state`, `viewModel`) when only one property matters. +- Do not add changing lambdas as keys unless you really want restarts on every lambda change. + +## Avoid stale captures + +For long-running effects that should not restart but need the latest callback or value, use `rememberUpdatedState`. + +```kotlin +@Composable +fun Timeout(onTimeout: () -> Unit) { + val latestOnTimeout by rememberUpdatedState(onTimeout) + + LaunchedEffect(Unit) { + delay(1_000) + latestOnTimeout() + } +} +``` + +Use this when the lifecycle is "start once" but the invoked lambda should stay fresh. Common cases: + +- A timeout or splash effect should not restart when `onTimeout` changes, but it should call the latest callback. +- A lifecycle observer should stay registered to the same owner, but invoke the latest `onStart` / `onStop` lambdas. +- A long-running collector should keep its collection lifecycle, but call the latest event handler. + +Do not use `rememberUpdatedState` to avoid choosing proper keys. If the changed value should restart the work, make it a key instead: + +```kotlin +// BAD: userId changes should restart the collection, not update a captured value. +val latestUserId by rememberUpdatedState(userId) +LaunchedEffect(Unit) { + repository.events(latestUserId).collect { event -> handle(event) } +} + +// GOOD: the collection lifecycle follows userId. +LaunchedEffect(userId) { + repository.events(userId).collect { event -> handle(event) } +} +``` + +`rememberUpdatedState` also does not make render state "non-recomposing." If the UI needs to display a changing value, read normal `State` in composition or use the deferred-read patterns in [`compose-state-deferred-reads`](../compose-state-deferred-reads/SKILL.md) for frame-rate values. + +## Collecting Flow + +Use `LaunchedEffect` for **side-effect/event flows**: snackbars, navigation events, analytics events, focus commands, or other streams where each emission triggers imperative work. + +```kotlin +LaunchedEffect(events) { + events.collect { event -> + snackbarHostState.showSnackbar(event.message) + } +} +``` + +Do not collect render state imperatively just to mutate local state. For UI state, collect near the state holder and pass plain values into the UI composable—the **state-holder vs UI split**, `collectAsStateWithLifecycle()` / `collectAsState()`, and preview-friendly wiring are covered in [`compose-state-holder-ui-split`](../compose-state-holder-ui-split/SKILL.md). Do not duplicate that architecture here. + +On Android, prefer lifecycle-aware collection where available; use `collectAsState()` on targets without lifecycle-aware APIs. + +For Compose state reads, use `snapshotFlow`: + +```kotlin +LaunchedEffect(listState) { + snapshotFlow { listState.firstVisibleItemIndex } + .distinctUntilChanged() + .collect { index -> analytics.visibleIndex(index) } +} +``` + +`snapshotFlow { ... }.map { ... }` without a terminal `collect` does nothing. + +## User events + +Use `rememberCoroutineScope()` when a click or gesture starts suspending work: + +```kotlin +@Composable +fun SaveButton(snackbarHostState: SnackbarHostState) { + val scope = rememberCoroutineScope() + + Button( + onClick = { + scope.launch { + snackbarHostState.showSnackbar("Saved") + } + }, + ) { + Text("Save") + } +} +``` + +Avoid "event flag" state just to trigger a `LaunchedEffect`. The click already is the event. + +## Registration and cleanup + +Use `DisposableEffect` for paired setup/teardown: + +```kotlin +@Composable +fun ObserveLifecycle(owner: LifecycleOwner, observer: LifecycleObserver) { + DisposableEffect(owner, observer) { + owner.lifecycle.addObserver(observer) + onDispose { + owner.lifecycle.removeObserver(observer) + } + } +} +``` + +Every registration path should have a matching `onDispose` cleanup path. + +## Common mistakes + +| Mistake | Fix | +|---|---| +| Network request directly in the composable body | Usually move to a ViewModel/state holder; use `LaunchedEffect` only for UI-owned keyed work | +| Analytics property written from the composable body | Use `SideEffect` when it should publish after every successful recomposition | +| Impression/event logged from the composable body | Use `LaunchedEffect(key)` when it should run once for that key | +| `LaunchedEffect(Unit)` captures changing `id` | Key by `id`, or use `rememberUpdatedState` if it must not restart | +| `rememberUpdatedState(id)` used so `LaunchedEffect(Unit)` keeps running after `id` changes | Hidden lifecycle bug | Key the effect by `id` | +| Long-lived effect invokes an old callback after recomposition | Stale capture | Wrap the callback with `rememberUpdatedState` and call the wrapper inside the effect | +| `LaunchedEffect(state) { ... }` restarts too often | Key by the specific property | +| `LaunchedEffect(...) { nonSuspendSetter() }` | Usually `SideEffect`; keep `LaunchedEffect` only for keyed one-shot/deferred work | +| Listener added in `LaunchedEffect` with no cleanup | Use `DisposableEffect` | +| Launching from click by setting `shouldShowSnackbar = true` | Use `rememberCoroutineScope()` in the click callback | + +## Red flags during review + +- "This only runs once" about code in a composable body. +- `LaunchedEffect(Unit)` in a function with changing parameters. +- A flow chain inside an effect with no terminal collection. +- Effects whose keys are chosen to silence lint instead of model lifecycle. +- Callback lambdas used from long-lived effects without either a key or `rememberUpdatedState`. diff --git a/DeviceMasker-main/.agents/skills/compose/compose-slot-api-pattern/SKILL.md b/DeviceMasker-main/.agents/skills/compose/compose-slot-api-pattern/SKILL.md new file mode 100644 index 000000000..7e1d4d0e0 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/compose/compose-slot-api-pattern/SKILL.md @@ -0,0 +1,198 @@ +--- +name: compose-slot-api-pattern +description: Use when designing or reviewing a reusable Jetpack Compose component whose visual regions vary by caller, or when primitive content parameters and boolean shape flags are accumulating. +--- + +# Compose: slot API pattern + +## Core principle + +A reusable Compose component's job is to lay things out, not to enumerate what it lays out. The moment you write `title: String, subtitle: String?, leadingIcon: ImageVector?, trailingIcon: ImageVector?, trailingText: String?, showSwitch: Boolean, switchValue: Boolean, onSwitchChange: (Boolean) -> Unit?, badge: String?, …`, the component has stopped describing a layout and started enumerating call sites — and the next call site will need a parameter the component doesn't have. + +The fix is to **delegate content to the caller** via `@Composable` lambda parameters. The component contributes structure (where the leading bit, headline, supporting bit, trailing bit go). The caller contributes everything that goes *in* those slots. + +Material 3's `ListItem` is the canonical example: every visual piece is a slot (`headlineContent`, `supportingContent`, `leadingContent`, `trailingContent`, `overlineContent`), not a primitive. That's not over-engineering — it's the design that scales to every list-item shape the design system needs without ever editing `ListItem` again. + +## When to use this skill + +You're designing or reviewing a Compose component intended for reuse (more than one call site, now or planned), its visual content varies by caller, and any of these is true: + +- Its signature has `title: String`, `icon: ImageVector`, `actionText: String?`, etc. — primitive types describing *content*. +- It has multiple optional-content parameters that vary by call site (`subtitle: String?`, `leadingIcon: ImageVector?`, `trailingText: String?`). +- It has boolean flags whose only purpose is to switch between content shapes (`showChevron: Boolean`, `showSwitch: Boolean`, `mode: Mode.Text | Mode.Switch | …`). +- It accepts a `String` parameter where one caller would want a `Text` with custom style, a second caller a `Text` with a `Badge`, a third caller a row of icons. +- It already has *one* slot (often `trailing` or `content`) and the rest of the parameters are still primitives. + +## 1. Replace primitive content with `@Composable` slots + +Where the component asks for caller-controlled *content*, prefer a `@Composable () -> Unit` slot. Where the slot is structurally required, leave it non-nullable with no default. Where it's optional, make it nullable with a `null` default. + +```kotlin +// ❌ BAD — primitive parameters; trailing area is the only slot; everything else is locked +@Composable +fun SettingsRow( + title: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + subtitle: String? = null, + leadingIcon: ImageVector? = null, + trailing: (@Composable () -> Unit)? = null, +) { … } +``` + +This shape *seems* fine because the call sites today fit (`title` is always single-line text, `leadingIcon` is always an `ImageVector`). The problem is the *next* call site: a row with a `Badge` next to the title, a leading slot that's a circular avatar (not an `ImageVector`), a subtitle that's a row of chips. Each forces either a new parameter, a new flag, or a workaround. + +```kotlin +// ✅ GOOD — every visual region is a slot; the row describes structure, not content +@Composable +fun SettingsRow( + headlineContent: @Composable () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, + supportingContent: (@Composable () -> Unit)? = null, + leadingContent: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, +) { … } +``` + +Call sites stay short because the typical content is a one-liner: + +```kotlin +SettingsRow( + headlineContent = { Text("Account") }, + leadingContent = { Icon(Icons.Default.Person, contentDescription = null) }, + trailingContent = { SettingsRowDefaults.Chevron() }, + onClick = { … }, +) +``` + +And the awkward cases that *would* have required new primitive parameters now don't: + +```kotlin +SettingsRow( + headlineContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Inbox") + Spacer(Modifier.width(8.dp)) + Badge { Text("3") } + } + }, + onClick = { … }, +) +``` + +### Slot naming + +- Use `xxxContent` for free-form `@Composable () -> Unit` slots (`headlineContent`, `supportingContent`, `trailingContent`) — matches Material 3. +- Use a singular noun (`title`, `icon`, `actions`) when the slot is semantically constrained and the component name disambiguates (`Scaffold(topBar = { … }, bottomBar = { … }, floatingActionButton = { … })`). +- Don't use `content` *and* other `xxxContent` slots together — pick one convention per component. + +## 2. Scope receivers when the slot emits into a layout + +If the slot's content will sit inside a `Row`/`Column`/`Box` whose layout features (`Modifier.weight`, `BoxScope.matchParentSize`, alignment) should be available to the caller, declare the slot as a receiver lambda: `@Composable RowScope.() -> Unit`. + +```kotlin +// ❌ BAD — actions render inside a Row, but callers can't use RowScope.weight() +@Composable +fun MyTopBar( + title: @Composable () -> Unit, + actions: @Composable () -> Unit = {}, // ← caller has no Row scope +) +``` + +```kotlin +// ✅ GOOD — caller gets RowScope; .weight() and alignment-by works inside +@Composable +fun MyTopBar( + title: @Composable () -> Unit, + actions: @Composable RowScope.() -> Unit = {}, +) +``` + +This is what makes `TopAppBar(actions = { IconButton(…); IconButton(…) })` work — the caller is implicitly inside a `RowScope`. + +Don't bolt a scope receiver onto every slot reflexively. The receiver should match the actual parent layout the slot emits into. If the slot is rendered inside a `Box`, use `BoxScope`. If it's inside a `Column`, use `ColumnScope`. If the parent is not a standard layout (or none of its scope APIs are useful in slot content), no receiver. + +## 3. Optional slots — nullable with `null` default + +For slots that may be absent, prefer `(@Composable () -> Unit)? = null` over `@Composable () -> Unit = {}`: + +```kotlin +// ❌ BAD — empty default; "no leading content" is the empty lambda +leadingContent: @Composable () -> Unit = {} + +// ✅ GOOD — null means "no slot"; the component can omit space/padding when absent +leadingContent: (@Composable () -> Unit)? = null +``` + +Why: with a nullable slot, the *component* can branch on `leadingContent != null` and skip the slot's container, spacing, padding entirely. With an empty default, the layout still allocates the slot — sometimes you see a stray padding or spacer around content that turned out to be nothing. The nullable form makes the "absent" case structurally distinct, which is almost always what you want. + +The trade-off: callers who pass an explicit empty `{}` to silence a slot now have to pass `null` or omit the argument. That's the right answer either way — they shouldn't be passing `{}`. + +## 4. Defaults live in `XxxDefaults` + +When you find yourself documenting "the trailing slot should usually be a chevron" or "pass `MaterialTheme.colorScheme.surface` for the default background", co-locate the helpers in a `XxxDefaults` object next to the component: + +```kotlin +object SettingsRowDefaults { + @Composable + fun Chevron() = Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + ) + + @Composable + fun TrailingValue(text: String) = Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} +``` + +Call sites stay declarative for the common cases and the slot is still fully open for one-offs: + +```kotlin +SettingsRow( + headlineContent = { Text("Notifications") }, + trailingContent = { SettingsRowDefaults.Chevron() }, + onClick = { … }, +) +``` + +This matches Material 3's `ButtonDefaults`, `TopAppBarDefaults`, etc. — defaults that are themselves composable belong here, not as new component parameters with `MaterialTheme.x.y` defaults expanded inline. + +## Quick reference + +| Symptom | Diagnosis | Fix | +|---|---|---| +| `title: String, subtitle: String?, leadingIcon: ImageVector?` on a reusable component | Primitive content params (§1) | Convert to `xxxContent: (@Composable () -> Unit)?` slots | +| Multiple boolean flags (`showChevron`, `showSwitch`) selecting trailing shapes | Enumerating shapes (§1) | One `trailingContent: (@Composable () -> Unit)?` slot | +| A `mode: Mode.Sealed` parameter listing variants | Same as flag soup (§1) | Slot it | +| `actions: @Composable () -> Unit = {}` inside a `Row` body | Missing scope receiver (§2) | `actions: @Composable RowScope.() -> Unit = {}` | +| `slot: @Composable () -> Unit = {}` for an optional area | Empty-lambda default (§3) | `slot: (@Composable () -> Unit)? = null` and branch on it | +| Component param `defaultColor: Color = MaterialTheme.colorScheme.surface` | Defaults inlined (§4) | Move to `XxxDefaults.color` and reference it | +| Common trailing content repeats at every call site | Missing default helper (§4) | Add `XxxDefaults.Chevron()` etc. | + +## When NOT to apply + +- **Single-use components.** A composable used in exactly one place, with no plan to reuse, doesn't benefit from slot flexibility — and the slot indirection makes the code harder to read for the one reader. Primitive params + inline content is fine. (As soon as a second call site appears, slot it.) +- **Design-system primitives where every caller must look identical.** A `Heading2(text: String)` exists *because* you want every H2 to look the same; making it `headlineContent: @Composable () -> Unit` invites callers to break the rule. Keep it primitive. (Conversely: if `Heading2` ever needs a badge inline, slot it.) +- **Semantic parameters the component intentionally owns.** If the component owns typography, iconography, accessibility wording, or product consistency, a primitive parameter may be the constraint you want. +- **Constrained-type parameters that genuinely are constrained.** A `Switch(checked: Boolean, onCheckedChange: ...)` doesn't need its checked indicator to be a slot. Booleans-with-callbacks are not "content." +- **Performance-critical fast paths** (rare in app code; common in framework primitives). A slot is an allocated lambda. In the deepest LazyList item layer, sometimes primitives win. If you're not writing the framework, this doesn't apply. + +## Red flags during review + +| Thought | Reality | +|---|---| +| "Title is *always* a String — making it a slot is over-engineering" | "Always today" is the trap. Material's `ListItem.headlineContent` exists because tomorrow someone wants a `Text + Badge`. The slot is `8` characters of extra wrapping at every call site (`{ Text(…) }`); the refactor to add a slot later edits every existing call site. | +| "Lambdas are heavier than strings" | At the scale of typical Compose UI, this isn't measurable — and the framework's own components (`Button`, `ListItem`, `TopAppBar`, `Scaffold`) all slot. If your component is in the hottest of hot paths, see "When NOT to apply." | +| "I'll add a slot later if someone asks" | The slot turns one parameter into two parameters (the slot itself + maybe an internal flag) and edits every call site. The shape change isn't a "later" change. | +| "I'll model the variants with a sealed `Trailing` type instead" | Sealed enumeration is bounded; slots are unbounded. A sealed type works *until* the day someone needs a variant you didn't anticipate — at which point you're back to editing the component. The slot avoids the cycle. | +| "The leading area is *always* an icon, the trailing area varies — I'll slot only the trailing" | This is the partial-slot trap. The "always-an-icon" assumption breaks the first time a row needs an avatar or a flag emoji or a coloured shape. Slot leading too. | +| "There's only one call site today" | If there's only one call site, you're probably not designing a reusable component yet. See "When NOT to apply" — primitives are fine for a true single-use. The moment you copy-paste it, slot it. | + +## Related + +- [`compose-modifier-and-layout-style`](../compose-modifier-and-layout-style/SKILL.md) — the modifier-parameter rule (§1–§3 there) travels with slot APIs. A reusable component takes a `modifier` parameter *and* slots its content; the caller owns both placement and what to place. diff --git a/DeviceMasker-main/.agents/skills/compose/compose-stability-diagnostics/SKILL.md b/DeviceMasker-main/.agents/skills/compose/compose-stability-diagnostics/SKILL.md new file mode 100644 index 000000000..b3a334fb4 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/compose/compose-stability-diagnostics/SKILL.md @@ -0,0 +1,140 @@ +--- +name: compose-stability-diagnostics +description: Use when writing or reviewing Jetpack Compose parameter stability, compiler reports, skippability, unstable UI state classes, collection parameters, or Kotlin 2.0+ strong skipping behavior. +--- + +# Compose stability diagnostics + +## Core principle + +Compose performance problems from parameters are about **whether inputs compare cheaply and predictably across recompositions**. With Kotlin 2.0.20+ strong skipping is enabled by default, so unstable parameters no longer automatically make restartable composables non-skippable. That does not make stability irrelevant: unstable parameters are compared by instance identity (`===`), stable parameters by equality (`equals`), and churny instances can still defeat skipping. + +First identify the compiler mode you are on, then read reports in that context. + +## When to use this skill + +- A composable or screen recomposes more than expected and parameter churn is suspected. +- A UI-state/model class is passed to composables and contains `List`, `Set`, `Map`, ranges, Java time/money types, or third-party types. +- `composables.txt` / `classes.txt` shows unstable parameters or non-skippable composables. +- A project uses Kotlin < 2.0.20, disables strong skipping, or has old Compose compiler report guidance. + +## 1. Start with strong skipping + +On Kotlin 2.0.20+, strong skipping is enabled by default. In that mode: + +- Restartable composables are skippable even when parameters are unstable, unless explicitly opted out. +- Stable parameters compare with `equals`. +- Unstable parameters compare with instance equality (`===`). +- Lambdas inside composables are automatically remembered based on captures. + +That means the question changes from "is this composable skippable at all?" to "will these parameters compare the way I expect, and are callers creating new unstable instances every frame?" + +For older compiler setups or strong skipping disabled, the legacy rule still matters: a restartable composable with unstable parameters may be restartable but not skippable. + +## 2. Generate compiler reports + +With Kotlin 2.0+ the Compose Compiler is configured through the Kotlin Gradle plugin: + +```kotlin +plugins { + alias(libs.plugins.android.application) // or android.library / jvm + alias(libs.plugins.kotlin.android) // or kotlin.multiplatform / kotlin.jvm + alias(libs.plugins.compose.compiler) +} + +if (providers.gradleProperty("composeReports").orNull == "true") { + composeCompiler { + reportsDestination = layout.buildDirectory.dir("compose_compiler") + metricsDestination = layout.buildDirectory.dir("compose_compiler") + } +} +``` + +Then build the variant whose compiler configuration you care about, for example: + +```bash +./gradlew :app:assembleRelease -PcomposeReports=true +``` + +Use release/non-debuggable builds for runtime profiling. Compiler reports are build-time outputs, so the important thing is matching the variant and compiler flags you ship. + +Key files: + +| File | What it tells you | +|---|---| +| `-classes.txt` | Stability of classes and properties | +| `-composables.txt` | Restartable/skippable status and parameter stability | +| `-composables.csv` | Same data in sortable form | +| `-module.json` | Aggregate metrics | + +## 3. Fix stability where semantics need it + +Pick the lightest fix that makes the type's immutability or equality semantics true. + +### Immutable collections + +`kotlin.collections.List` is an interface; Compose cannot know the runtime implementation is immutable. Prefer `kotlinx.collections.immutable` at UI-state boundaries: + +```kotlin +// Before: unstable collection interfaces +data class UiState(val items: List, val tags: Set) + +// After: immutable collection contracts +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet + +data class UiState(val items: ImmutableList, val tags: ImmutableSet) +``` + +Producers convert once at the boundary with `.toImmutableList()` / `.toImmutableSet()`. + +### `@Immutable` / `@Stable` + +- Use `@Immutable` when every property is effectively immutable and equality describes all observable state. +- Use `@Stable` for types whose mutable state is observable by Compose, typically via `MutableState`. + +Do not annotate to silence a report. A false stability promise can produce stale UI. + +### Third-party immutable types + +For types you cannot annotate, use `stabilityConfigurationFiles`: + +```kotlin +composeCompiler { + stabilityConfigurationFiles.add( + rootProject.layout.projectDirectory.file("compose_stability.conf"), + ) +} +``` + +```text +java.math.BigDecimal +java.math.BigInteger +java.time.* +kotlinx.datetime.* +``` + +Only list types you are willing to promise are immutable. Do not list mutable types such as `java.util.Date`. + +## Quick reference + +| Symptom | Diagnosis | Fix | +|---|---|---| +| Kotlin 2.0.20+ but old docs say unstable means non-skippable | Strong skipping changed the default | Check comparison semantics and instance churn instead | +| `unstable val items: List` | Interface collection | Use `ImmutableList` or another true immutable wrapper | +| `unstable val price: BigDecimal` | External immutable type | Add to stability config | +| `@Immutable` on a type with mutable internals | False promise | Fix the model or remove the annotation | +| Composable skips poorly despite strong skipping | New unstable instance each recomposition | Remember, hoist, or make the type stable/equality-based | +| Reports not generated | Compose compiler plugin missing or flag not set | Apply `org.jetbrains.kotlin.plugin.compose` and enable destinations | + +## When NOT to apply + +- The issue is a fast-changing `State` read in composition, such as scroll or animation. Use `compose-state-deferred-reads`. +- The recomposition count matches real data changes. +- The bug is wrong data or stale state, not excess work. +- The code is test-only and readability is more important than report cleanliness. + +## Related + +- [`compose-state-deferred-reads`](../compose-state-deferred-reads/SKILL.md) - frame-rate state should often be read in layout/draw rather than composition. +- [`compose-recomposition-performance`](../compose-recomposition-performance/SKILL.md) - entry point when you are not sure which recomposition axis is involved. diff --git a/DeviceMasker-main/.agents/skills/compose/compose-state-authoring/SKILL.md b/DeviceMasker-main/.agents/skills/compose/compose-state-authoring/SKILL.md new file mode 100644 index 000000000..887a4dbb1 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/compose/compose-state-authoring/SKILL.md @@ -0,0 +1,162 @@ +--- +name: compose-state-authoring +description: Use when writing or reviewing Jetpack Compose code with bare local var in a @Composable, remember { mutableStateOf(...) }, mutableStateListOf/mutableStateMapOf, or @ReadOnlyComposable. +--- + +# Compose state authoring + +Not every `remember { … }` belongs here. This skill covers **local UI state** (`remember { mutableStateOf(…) }`, `mutableStateListOf` / `mutableStateMapOf`) and **`@ReadOnlyComposable`**. Other remembered APIs live in focused skills: + +- **`rememberCoroutineScope` / `rememberUpdatedState`** → [`compose-side-effects`](../compose-side-effects/SKILL.md) +- **`rememberLazyListState` / `rememberScrollState`** used for frame-rate reads → [`compose-state-deferred-reads`](../compose-state-deferred-reads/SKILL.md) +- **Focus navigation, focus state, `FocusRequester` ownership, behavior** → [`compose-focus-navigation`](../compose-focus-navigation/SKILL.md) + +## Core principle + +A `@Composable` is a function the runtime re-runs whenever its inputs change. Writing local state correctly comes down to two questions: + +1. **Mutable local state** — does my `var` survive recomposition *and* trigger it? If not, it silently resets on every recompose and writes are invisible. +2. **What kind of composable is this?** — do I *mutate* composition (place layout nodes, allocate slots, `remember`) or only *read* it? If only read, `@ReadOnlyComposable` lets the runtime skip work. + +Get either wrong and the symptoms are subtle: state that vanishes or optimizations that don't apply. + +## When to use this skill + +You're writing or reviewing Compose code and you see any of these: + +- `var x = …` inside a `@Composable fun` or any composable lambda (`Column { var x = … }`) +- A `@Composable fun` (or `@Composable get()` property accessor) whose body never lays anything out +- `@ReadOnlyComposable` on a function that calls `Text`, `Box`, `Column`, `remember`, … +- A composable whose visible state mysteriously resets on rotation, theme change, or recomposition + +## 1. `var` in a composable must be State-backed + +Recomposition re-executes the composable from the top. A local `var` is *re-initialized* on every pass — last recompose's value is gone, and writing to it doesn't tell the runtime to recompose. + +```kotlin +// ❌ BAD — counter resets on every recomposition; clicks never update the UI +@Composable +fun Counter() { + var count = 0 + Button(onClick = { count++ }) { Text("$count") } +} + +// ❌ ALSO BAD — same rule applies inside composable content lambdas +@Composable +fun Wrapper() { + Row { + var count = 0 // Row's content lambda is @Composable too + // … + } +} +``` + +```kotlin +// ✅ GOOD — `remember` survives recomposition, `mutableStateOf` triggers it +@Composable +fun Counter() { + var count by remember { mutableStateOf(0) } + Button(onClick = { count++ }) { Text("$count") } +} +``` + +Two pieces and both matter: + +- `remember { … }` — *survives recomposition*. Without it the value is re-created each time. +- `mutableStateOf(…)` — *triggers recomposition*. Without it, mutations are invisible to the runtime. + +For collections, prefer `mutableStateListOf` / `mutableStateMapOf` (also `remember`-ed). They emit Snapshot reads on every read and Snapshot writes on every mutation. A `remember { mutableStateOf(mutableListOf()) }` followed by `list.add(x)` will *not* recompose, because `MutableList.add` doesn't go through the State setter — you'd have to replace the value (`state = state + x`). + +### When this rule does NOT apply + +- **Inside `remember { … }`'s producer block.** That runs once per key change, not on every recompose. A local `var` there is fine: `val builder = remember { mutableListOf().apply { var n = 0; … } }`. +- **In non-`@Composable` lambdas passed *out* of a composable.** `onClick = { var a = 0; … }` is a plain `() -> Unit`. Local vars there are normal Kotlin. +- **In plain (non-`@Composable`) helper functions.** Only composable scopes are affected. + +## 2. The `@ReadOnlyComposable` contract + +`@ReadOnlyComposable` declares that a composable *only reads* composition state — no `Text`, no `Box`, no `remember`, no layout nodes, no positional slots. The runtime can then skip allocating a group for the call, which matters for fast accessor-style composables (`MaterialTheme.colorScheme`, `LocalDensity.current`, design-system token accessors). + +The contract is **bidirectional**: + +- **Add `@ReadOnlyComposable`** when every composable call your body makes is itself `@ReadOnlyComposable` (or there are no composable calls at all — for example a function that only reads `LocalFoo.current` and returns a value). +- **Don't add it** if you call any non-read-only composable. The optimization assumes you don't participate in composition; violating that produces incorrect recomposition behaviour for callers. + +```kotlin +// ✅ GOOD — only reads composition locals, no layout, no remember +@Composable +@ReadOnlyComposable +fun appSpacing(): Dp = LocalDimensions.current.spacing + +// ✅ GOOD — composable property getter; same rule +val accent: Color + @Composable @ReadOnlyComposable + get() = MaterialTheme.colorScheme.tertiary +``` + +```kotlin +// ❌ BAD — annotated read-only but lays out a Box; contract violated +@Composable +@ReadOnlyComposable +fun Header(): Int { + Box {} // ← non-read-only composable call + return 42 +} + +// ❌ BAD — calls a normal composable from a read-only one +@Composable +@ReadOnlyComposable +fun computed(): Int = nonReadOnlyHelper() +``` + +### Heuristic for "should I add it" + +If the body contains any of these, **do not** add `@ReadOnlyComposable`: + +- A layout call: `Box`, `Column`, `Row`, `LazyColumn`, `Text`, anything from `androidx.compose.foundation.layout` or `androidx.compose.material*`. +- A side-effect call: `LaunchedEffect`, `DisposableEffect`, `SideEffect`, `produceState`. +- `remember { … }` — positional memoization is composition state. +- A `@Composable` lambda invocation (`content()`). +- An invocation of a non-`@ReadOnlyComposable` composable function. + +If the body is only reading `Local*.current`, calling other `@ReadOnlyComposable` functions, or doing pure computation, **add** it. + +### When this rule does NOT apply + +- **`override fun` declarations.** The annotation is part of the contract; if the base isn't `@ReadOnlyComposable`, you can't make an override one. Refactor the base, or accept the override pays the group-creation cost. +- **Abstract declarations.** No body to check. + +## Related: side effects live in their own skill + +If a composable needs `LaunchedEffect`, `DisposableEffect`, `SideEffect`, `rememberCoroutineScope`, `rememberUpdatedState`, `snapshotFlow`, snackbar/navigation handling, analytics, or Flow collection, use [`compose-side-effects`](../compose-side-effects/SKILL.md). + +Focus splits by question: **navigation, focus state, `FocusRequester` ownership, behavior** → [`compose-focus-navigation`](../compose-focus-navigation/SKILL.md); **when** to call imperative `requestFocus` (effect timing, lifecycle, keys, API choice) → [`compose-side-effects`](../compose-side-effects/SKILL.md). + +This skill is about authoring Compose state correctly. `rememberUpdatedState` is effect capture state, not a general replacement for `remember { mutableStateOf(...) }`. Side effects have separate lifecycle and keying rules, and keeping them in one focused skill avoids two sources of truth. + +## Quick reference + +| Symptom | Diagnosis | Fix | +|---|---|---| +| `var x = …` inside `@Composable fun` body | Not recomposition-safe (§1) | `var x by remember { mutableStateOf(…) }` | +| `var x = …` inside `Column { … }` / `Row { … }` content lambda | Same — content lambdas are `@Composable` (§1) | Same fix | +| `remember { mutableStateOf(list) }` then `.add(x)` not recomposing | Mutation bypasses State setter | Use `mutableStateListOf`, or replace the value: `state = state + x` | +| `@Composable fun` with no `Text`/`Box`/`remember`/effect calls | Could be `@ReadOnlyComposable` (§2) | Add `@ReadOnlyComposable` above `@Composable` | +| `@ReadOnlyComposable` function that calls `Box {}` / `Column {}` / a normal composable | Contract violation (§2) | Remove `@ReadOnlyComposable` | + +## When NOT to apply + +- **Tests** with `composeTestRule.setContent { … }` follow the same rules — they're production composables. +- **`produceState`** has its own producer block that runs in a coroutine; you don't need `LaunchedEffect` *inside* it. +- **`derivedStateOf`** has its own concerns around stability and equality — out of scope here; it's about *preventing* recomposition, not authoring state. +- **`override`s** of read-only-composable declarations: the annotation is fixed by the base; you can't add or remove it locally. + +## Red flags during review + +| Thought | Reality | +|---|---| +| "It's a small composable, the bare `var` is fine" | Recomposition can fire at any time. The reset is non-deterministic by design — and a single bug report later. | +| "I'll add `@ReadOnlyComposable` because the function looks simple" | "Simple" isn't the criterion. "Makes only read-only calls" is. | +| "I always reach for `LaunchedEffect` because it's the one I know" | Use `compose-side-effects`; effect API choice depends on lifecycle and keys. | +| "I'll just `.add()` to the remembered list" | A `mutableStateOf(List)` doesn't observe internal mutation — use `mutableStateListOf` or replace the value. | +| "The override needs `@ReadOnlyComposable` to match what it does" | If the base isn't `@ReadOnlyComposable`, you can't add it to an override. Refactor the base instead. | diff --git a/DeviceMasker-main/.agents/skills/compose/compose-state-deferred-reads/SKILL.md b/DeviceMasker-main/.agents/skills/compose/compose-state-deferred-reads/SKILL.md new file mode 100644 index 000000000..c3509078a --- /dev/null +++ b/DeviceMasker-main/.agents/skills/compose/compose-state-deferred-reads/SKILL.md @@ -0,0 +1,141 @@ +--- +name: compose-state-deferred-reads +description: Use when Jetpack Compose code reads scroll, animation, gesture, or other frame-rate State in composition, passes changing values across composable boundaries, or uses value-form layout/draw modifiers. +--- + +# Compose state deferred reads + +## Core principle + +State reads invalidate the phase that reads them. If a `State` is read in a composable body, changes invalidate composition. If it is read in layout or draw, changes can invalidate only layout or draw. Frame-rate state such as scroll offsets, animations, and drag positions usually belongs in layout/draw, not composition. + +The fix is structural: keep the `State` or a provider lambda, and read the value inside a layout/draw callback. + +## When to use this skill + +- `val x by animate*AsState(...)` is passed to `Modifier.offset(x = ...)`, `Modifier.size(...)`, `Modifier.graphicsLayer(...)`, or another value-form modifier. +- `LazyListState.firstVisibleItemScrollOffset`, `ScrollState.value`, `Animatable.value`, or gesture state is read in a composable body. +- A composable takes `scrollOffset: Int`, `progress: Float`, `dragOffset: Offset`, or similar frame-rate values. +- Recomposition counters climb during scroll, animation, or gestures even when data is stable. + +## 1. Prefer block-form modifiers + +Several modifiers have value forms and block forms. The value form receives values already read in composition; the block form can read during layout or draw. + +```kotlin +// Before: animated value read in composition by the `by` delegate +@Composable +fun SelectionPill(selectedIndex: Int) { + val offsetX by animateDpAsState(120.dp * selectedIndex) + Box(Modifier.offset(x = offsetX)) +} + +// After: State is kept, value is read in the layout-phase offset block +@Composable +fun SelectionPill(selectedIndex: Int) { + val offsetX = animateDpAsState(120.dp * selectedIndex) + Box( + Modifier.offset { + IntOffset(offsetX.value.roundToPx(), 0) + }, + ) +} +``` + +Common replacements: + +| Composition read | Deferred read | +|---|---| +| `Modifier.offset(x = animatedX)` | `Modifier.offset { IntOffset(animatedX.value.roundToPx(), 0) }` | +| `Modifier.graphicsLayer(translationY = y)` | `Modifier.graphicsLayer { translationY = yProvider() }` | +| `val radius by animateFloatAsState(...); drawBehind { drawCircle(radius = radius) }` | `val radius = animateFloatAsState(...); drawBehind { drawCircle(radius = radius.value) }` | + +The `drawBehind` block is already draw-phase; the important part is that the `State.value` read also happens inside that block. + +## 2. Pass providers across composable boundaries + +If the fast-changing value would cross a composable boundary, pass a provider lambda instead of a snapshot value: + +```kotlin +// Before: HomeScreen reads scroll offset in composition and passes the value down +@Composable +fun HomeScreen() { + val listState = rememberLazyListState() + LazyColumn(state = listState) { + item { HeroImage(scrollOffset = listState.firstVisibleItemScrollOffset) } + } +} + +@Composable +fun HeroImage(scrollOffset: Int, modifier: Modifier = Modifier) { + AsyncImage( + model = "...", + modifier = modifier.graphicsLayer(translationY = -scrollOffset / 2f), + ) +} + +// After: the only read happens inside graphicsLayer +@Composable +fun HomeScreen() { + val listState = rememberLazyListState() + LazyColumn(state = listState) { + item { + HeroImage( + scrollOffsetProvider = { + if (listState.firstVisibleItemIndex == 0) { + listState.firstVisibleItemScrollOffset + } else { + 0 + } + }, + ) + } + } +} + +@Composable +fun HeroImage(scrollOffsetProvider: () -> Int, modifier: Modifier = Modifier) { + AsyncImage( + model = "...", + modifier = modifier.graphicsLayer { + translationY = -scrollOffsetProvider() / 2f + }, + ) +} +``` + +Suffix provider parameters with `Provider` when that clarifies the deferred-read contract. + +## 3. Other layout/draw read sites + +State reads can also be deferred inside: + +- `Modifier.layout { measurable, constraints -> ... }` +- Custom `Alignment.align(...)` +- `drawWithContent`, `drawBehind`, and other draw modifiers +- Block-form layer/layout modifiers such as `graphicsLayer { ... }` and `offset { ... }` + +Use these when the state changes where something is placed or painted. If the state decides *which composables exist*, it belongs in composition. + +## Quick reference + +| Symptom | Diagnosis | Fix | +|---|---|---| +| `val x by animateFloatAsState(...)` then `Modifier.offset(...)` | `by` reads in composition | Keep `State` and read `.value` in `offset {}` | +| `Modifier.graphicsLayer(translationY = animatedY)` | Property-argument form uses composition values | Use `graphicsLayer { translationY = ... }` | +| `Child(scrollOffset = listState.firstVisibleItemScrollOffset)` | Fast-changing value crosses boundary | `Child(scrollOffsetProvider = { ... })` | +| Draw block still recomposes every frame | Value was read before draw block | Move the `State.value` read inside the draw block | +| State chooses between different UI branches | Composition decision | Keep the read in composition | + +## When NOT to apply + +- The state controls which composables are emitted. +- The animation is one-shot, cheap, and clarity wins. +- You are writing tests where direct value assertions are simpler. +- Runtime evidence shows recomposition is not the bottleneck. + +## Related + +- [`compose-state-holder-ui-split`](../compose-state-holder-ui-split/SKILL.md) - where state-holder vs plain UI split applies when passing providers/lambdas across boundaries. +- [`compose-stability-diagnostics`](../compose-stability-diagnostics/SKILL.md) - parameter stability and compiler reports. +- [`compose-modifier-and-layout-style`](../compose-modifier-and-layout-style/SKILL.md) - child composables need a normal `modifier` parameter before callers can move visual reads into modifiers. diff --git a/DeviceMasker-main/.agents/skills/compose/compose-state-hoisting/SKILL.md b/DeviceMasker-main/.agents/skills/compose/compose-state-hoisting/SKILL.md new file mode 100644 index 000000000..dcf504d7f --- /dev/null +++ b/DeviceMasker-main/.agents/skills/compose/compose-state-hoisting/SKILL.md @@ -0,0 +1,135 @@ +--- +name: compose-state-hoisting +description: "Use when deciding where Jetpack Compose UI element state or UI logic should live: local remember state, hoisted composable parameters, a plain state holder class, or a screen-level ViewModel/component." +--- + +# Compose state hoisting + +## Core principle + +Hoist state only as far as the logic needs it. Keep simple UI element state local, move shared UI element state to the lowest common composable owner, extract a plain state holder when UI-only behavior becomes a concept, and use a screen state holder when business logic or app data is involved. + +## Decision guide + +| Situation | Owner | +|---|---| +| One composable reads/writes simple state | Keep local with `remember` / `rememberSaveable` | +| Sibling or parent composables need to read/write it | Hoist state and events to their lowest common composable ancestor | +| Related UI element state plus UI logic is making a composable hard to read, preview, or test | Extract a plain state holder class remembered in composition | +| Repository calls, persistence, business rules, or screen UI state production are involved | Use a screen-level state holder such as a `ViewModel` or component | + +UI element state includes things like expansion, sheet visibility, scroll position, focus, text field editing state, selection, and animation/interaction state. Screen UI state is app data prepared for display. + +If UI element state is an input to business logic, it may need to live in the screen state holder too. For example, text used to query repository-backed suggestions belongs with the state holder that produces those suggestions. + +## Plain state holder trigger + +Extract a plain state holder when several of these are true: + +- Multiple related `remember` values are coordinated by the same callbacks. +- Scroll, focus, text, selection, or sheet state needs named operations such as `clear`, `submit`, `jumpToTop`, or `openFilters`. +- Derived UI flags are scattered through the composable. +- Child composables receive mechanics they do not conceptually own. +- Previews or tests must drive a long sequence of UI details to check one behavior. +- Helper functions need many state parameters just to keep the composable readable. + +Do not extract for one boolean, one text field, or trivial show/hide logic. Ceremony is not separation of concerns. + +## Pattern + +Use a plain class for UI element state and UI logic, plus a `remember...State` function for composition-owned objects: + +```kotlin +@Stable +class ProductSearchState( + query: String, + private val listState: LazyListState, + private val focusRequester: FocusRequester, +) { + var query by mutableStateOf(query) + private set + + var filtersOpen by mutableStateOf(false) + private set + + val canClear: Boolean + get() = query.isNotEmpty() + + fun updateQuery(value: String) { + query = value + } + + fun clear() { + query = "" + focusRequester.requestFocus() + } + + suspend fun jumpToTop() { + listState.animateScrollToItem(0) + } +} + +@Composable +fun rememberProductSearchState( + initialQuery: String = "", + listState: LazyListState = rememberLazyListState(), + focusRequester: FocusRequester = remember { FocusRequester() }, +): ProductSearchState { + return remember(listState, focusRequester) { + ProductSearchState(initialQuery, listState, focusRequester) + } +} +``` + +The composable renders from the state holder and calls intent-style methods. If a parent needs to coordinate the same UI behavior, accept the state holder as a parameter with a default: + +```kotlin +@Composable +fun ProductSearchPanel( + state: ProductSearchState = rememberProductSearchState(), + modifier: Modifier = Modifier, +) { + val scope = rememberCoroutineScope() + + SearchField( + query = state.query, + onQueryChange = state::updateQuery, + onClear = state::clear, + ) + + JumpToTopButton(onClick = { + scope.launch { state.jumpToTop() } + }) +} +``` + +## Composition ownership + +Plain state holders created with `remember` follow the composable lifecycle. This makes them a good home for Compose UI objects such as `LazyListState`, `FocusRequester`, `PagerState`, `DrawerState`, and `TextFieldState`. + +Keep suspend UI operations that require a frame clock, such as scroll or drawer animations, in a composition-scoped coroutine (`rememberCoroutineScope`, `LaunchedEffect`, or another composition-owned scope). Do not move those calls to `viewModelScope`. + +## Saving state + +Use `rememberSaveable` or a custom `Saver` only for values that should survive Activity or process recreation, such as a query string, selected filter IDs, or a current tab key. + +Do not try to save runtime objects like `LazyListState`, `FocusRequester`, coroutine scopes, or callbacks directly. Save the minimal serializable values needed to rebuild behavior. + +## Common mistakes + +| Mistake | Fix | +|---|---| +| Hoisting every local state value to a parent "just in case" | Hoist to the lowest owner that actually reads or writes it | +| Extracting a plain state holder for one boolean | Keep simple private UI state local | +| Putting repository calls or product rules in a Compose state holder | Move that logic to a screen state holder such as a `ViewModel` or component | +| Keeping text or selection local when it drives repository-backed screen state | Move that input to the screen state holder with the business logic | +| Passing a state holder deep into unrelated children | Pass plain values and callbacks unless the child truly coordinates the holder's behavior | +| Treating the holder as a dumping ground for a whole screen | Split by cohesive UI behavior, such as search input, sheet coordination, or list controls | +| Calling animation suspend functions from `viewModelScope` | Use a composition-scoped coroutine | + +## Related + +- [`compose-state-authoring`](../compose-state-authoring/SKILL.md) — correct local `remember` and mutable state authoring. +- [`compose-state-holder-ui-split`](../compose-state-holder-ui-split/SKILL.md) — split screen state-holder wiring from plain state-driven UI rendering. +- [`compose-side-effects`](../compose-side-effects/SKILL.md) — choose effect APIs and composition-scoped coroutine boundaries. +- [`compose-focus-navigation`](../compose-focus-navigation/SKILL.md) — focus state, requesters, and keyboard/D-pad behavior. diff --git a/DeviceMasker-main/.agents/skills/compose/compose-state-holder-ui-split/SKILL.md b/DeviceMasker-main/.agents/skills/compose/compose-state-holder-ui-split/SKILL.md new file mode 100644 index 000000000..9aaaba124 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/compose/compose-state-holder-ui-split/SKILL.md @@ -0,0 +1,156 @@ +--- +name: compose-state-holder-ui-split +description: Use when a Jetpack Compose screen-level composable takes a ViewModel/component/controller, collects state or effects, handles navigation/snackbars, or wires callbacks while also rendering layout. +--- + +# Compose: state holder/UI split + +## Core principle + +Separate state-holder wiring from UI rendering. The state-holder composable talks to ViewModels, components, flows, navigation, and side effects. The UI composable takes plain immutable UI state plus callbacks and describes layout. + +This keeps screens previewable, testable, and easier to reuse across Android, Desktop, TV, and KMP/CMP targets. + +## When to use this skill + +Use this when a Compose screen: + +- Takes a ViewModel, component, controller, navigator, repository, or service directly. +- Collects app/business state or side effects in the same function that lays out most UI. +- Passes a whole state holder into child composables instead of explicit state and callbacks. +- Is hard to preview because it needs dependency injection, navigation, lifecycle, or fake services. +- Has UI tests that must construct a full app stack to verify a simple layout branch. + +## The pattern + +Use a small public state-holder composable: + +```kotlin +@Composable +fun ProfileScreen(component: ProfileComponent, modifier: Modifier = Modifier) { + val state by component.state.collectAsStateWithLifecycle() + + ProfileScreen( + state = state, + onNameChange = component::onNameChange, + onSaveClick = component::save, + onBackClick = component::back, + modifier = modifier, + ) +} +``` + +Then put UI in a plain composable that knows nothing about the state holder: + +```kotlin +@Composable +fun ProfileScreen( + state: ProfileUiState, + onNameChange: (String) -> Unit, + onSaveClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ProfileContent( + name = state.name, + isSaving = state.isSaving, + canSave = state.canSave, + onNameChange = onNameChange, + onSaveClick = onSaveClick, + onBackClick = onBackClick, + modifier = modifier, + ) +} +``` + +Private content functions can break up layout: + +```kotlin +@Composable +private fun ProfileContent( + name: String, + isSaving: Boolean, + canSave: Boolean, + onNameChange: (String) -> Unit, + onSaveClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + // Layout only. +} +``` + +## Rules of thumb + +| Concern | State-holder composable | UI composable | +|---|---|---| +| Collect ViewModel/component state | Yes | No | +| Collect one-shot effects | Yes, or a tiny sibling effect handler | Usually no | +| Hold dependency-injected objects | Yes | No | +| Accept immutable UI state | Usually passes it through | Yes | +| Accept lambdas for user events | Wires them | Calls them | +| Own layout, modifiers, semantics, test tags | No/minimal | Yes | +| Own UI-local state like scroll, focus, text input, animation, interaction | Sometimes seeds it | Yes | +| Preview/screenshot friendly | Not necessarily | Yes | + +The "no collection in UI composables" rule is about app/business state and side-effect streams. Plain UI composables can still own UI-local framework state: `rememberScrollState`, `rememberLazyListState`, `FocusRequester`, focus state, animation state, `TextFieldState`, `MutableInteractionSource.collectIsPressedAsState()`, and similar behavior that belongs to the rendered widget. + +If that UI-local state grows into coordinated behavior with multiple related fields and operations, use [`compose-state-hoisting`](../compose-state-hoisting/SKILL.md) to decide whether it should become a plain state holder class remembered in composition. + +## What to pass + +Pass the smallest useful UI contract: + +- Prefer a dedicated `UiState`/`State` object over many unrelated primitives when the screen has real state. +- Prefer explicit lambdas (`onRetryClick`, `onItemSelected`) over passing a whole component. +- Keep domain models out of the UI composable if they force business rules into UI. Map to UI models when the UI needs a different shape. +- Keep navigation as callbacks. The UI composable says "user clicked back", not "navigate to route X". +- Frame-rate or UI-local values that should not force whole-tree recomposition when they change: prefer provider lambdas and deferred reads per [`compose-state-deferred-reads`](../compose-state-deferred-reads/SKILL.md). + +## Side effects + +[`compose-side-effects`](../compose-side-effects/SKILL.md) covers effect APIs (`LaunchedEffect`, `DisposableEffect`, `SideEffect`), keys, cleanup, and `rememberUpdatedState`. + +Handle effects near the state holder, where the effect source and imperative target are both available: + +```kotlin +@Composable +fun ProfileScreen(component: ProfileComponent, snackbarHostState: SnackbarHostState) { + val state by component.state.collectAsStateWithLifecycle() + + LaunchedEffect(component) { + component.effects.collect { effect -> + when (effect) { + ProfileEffect.Saved -> snackbarHostState.showSnackbar("Saved") + } + } + } + + ProfileScreen(state = state, onSaveClick = component::save) +} +``` + +If effect handling grows, extract `ProfileEffects(component, snackbarHostState)` rather than pushing the component into the UI composable. + +## Common mistakes + +| Mistake | Why it hurts | Fix | +|---|---|---| +| `fun Screen(viewModel: MyViewModel)` contains all layout | Hard to preview/test without Android lifecycle and DI | Add a plain UI overload that takes `state` and callbacks | +| Child composables take `component` | Dependencies leak through the tree | Pass only the state/callbacks that child needs | +| UI composable launches navigation | UI becomes coupled to app routing | Expose `onBackClick`, `onItemClick`, etc. | +| UI composable collects app/business flows | Collection lifecycle is hidden in layout | Collect near the state holder and pass values down | +| UI-local state is hoisted into the state holder for no reason | State holder starts owning layout mechanics | Keep scroll/focus/animation/text-field interaction state in the UI composable when it is only UI behavior | +| Every tiny composable gets a state-holder overload | Too much ceremony | Split at screen/section boundaries, not every `Row` | + +## When NOT to apply + +- Tiny one-off composables that already take plain values and callbacks. +- Design-system primitives such as `Button`, `Card`, or `ListItem`; those should expose slots and modifiers, not state holders. +- Cases where the state-holder composable would only forward one primitive and add no isolation. + +## Related + +- [`compose-ui-testing-patterns`](../compose-ui-testing-patterns/SKILL.md) — testing plain state-driven UI composables without the full app graph. +- [`compose-state-hoisting`](../compose-state-hoisting/SKILL.md) — deciding where UI element state and UI logic should live, including plain state holder classes. +- [`kotlin-multiplatform-expect-actual`](../kotlin-multiplatform-expect-actual/SKILL.md) — platform services, native views, and expect/interface boundaries when shared UI meets platform-specific leaves. diff --git a/DeviceMasker-main/.agents/skills/compose/compose-ui-testing-patterns/SKILL.md b/DeviceMasker-main/.agents/skills/compose/compose-ui-testing-patterns/SKILL.md new file mode 100644 index 000000000..fce3711af --- /dev/null +++ b/DeviceMasker-main/.agents/skills/compose/compose-ui-testing-patterns/SKILL.md @@ -0,0 +1,178 @@ +--- +name: compose-ui-testing-patterns +description: Use when writing or reviewing Jetpack Compose UI tests, screenshot tests, previews, semantics assertions, fake image loading, keyboard input, focus assertions, interaction state (hover/pressed/focused), or tests for plain state-driven UI composables. +--- + +# Compose: UI testing patterns + +## Core principle + +Test the smallest UI contract that proves the behavior. Prefer plain state-driven UI tests with callbacks. Add integration only when lifecycle, navigation, DI, or platform behavior is the thing under test. + +## Test target choice + +| What you need to prove | Test shape | +|---|---| +| Text, button, loading/error branch, conditional content | Plain UI Compose test | +| Callback wiring from click/input | Plain UI Compose test | +| Focus navigation or keyboard behavior | Compose test with key input | +| Visual layout, clipping, elevation, typography, image composition | Screenshot test | +| State holder updates UI correctly | State holder/unit test plus one wiring smoke test | +| Hover, pressed, focused, dragged interaction state | Plain UI test with MutableInteractionSource | +| Navigation, lifecycle, DI integration | Integration test | + +## Prefer plain UI tests + +If the screen has a state holder/UI split, test the plain UI composable: + +```kotlin +composeTestRule.setContent { + ProfileScreen( + state = ProfileUiState(name = "Ada", canSave = true), + onNameChange = {}, + onSaveClick = { saved = true }, + onBackClick = {}, + ) +} + +composeTestRule.onNodeWithText("Ada").assertIsDisplayed() +composeTestRule.onNodeWithText("Save").performClick() + +assertThat(saved).isTrue() +``` + +This avoids constructing ViewModels, components, repositories, navigation, and dependency graphs for layout behavior. + +## Semantics first + +Assert semantics when behavior is semantic: + +- Text exists: `onNodeWithText`. +- Button is enabled/disabled: `assertIsEnabled`, `assertIsNotEnabled`. +- Content is selected/focused/toggled: use semantics assertions. +- Content is absent: `assertDoesNotExist`. + +Use test tags for nodes that have no stable user-visible text or where multiple nodes share text. Do not use tags as the first choice for all assertions; user-visible semantics are usually stronger. + +## Callback testing + +Use simple counters or captured values: + +```kotlin +var selectedId: String? = null + +composeTestRule.setContent { + ItemList( + items = listOf(ItemUi("movie-1", "Movie")), + onItemClick = { selectedId = it }, + ) +} + +composeTestRule.onNodeWithText("Movie").performClick() + +assertThat(selectedId).isEqualTo("movie-1") +``` + +For plain captured callback values, a direct assertion after the action is usually enough. Use `runOnIdle` when the assertion needs Compose to finish applying snapshot state, recomposition, or queued UI work before reading the result. + +## Interaction state with MutableInteractionSource + +When a composable's appearance or behavior depends on interaction state (hover, focus, press, drag), inject a `MutableInteractionSource` and emit the desired state directly. Do not try to simulate pointer/mouse events to trigger interaction states — that approach is fragile, environment-dependent, and produces flaky tests. + +```kotlin +val interactionSource = MutableInteractionSource() + +composeTestRule.setContent { + OutlinedButton( + onClick = {}, + interactionSource = interactionSource, + ) +} + +// Assert default (un-hovered) state +composeTestRule.onNodeWithText("OutlinedButton").assertIsDisplayed() + +// Emit hover — interactionSource.emit is a suspend function, +// so call it from a test coroutine scope. +TestScope().launch { + interactionSource.emit(HoverInteraction.Enter()) +} + +composeTestRule.waitForIdle() + +// Assert the visual/semantic change that hover produces +// (e.g., border color, elevation, or capture for screenshot test) +composeTestRule.onNodeWithText("OutlinedButton").assertIsDisplayed() +``` + +The same pattern works for `PressInteraction.Press` / `Release` / `Cancel`, `FocusInteraction.Focus` / `Unfocus`, and `DragInteraction.Start` / `Stop` / `Cancel`. Emit the entry interaction, `waitForIdle`, then assert the result. + +Key points: + +- **Always inject `MutableInteractionSource`** rather than relying on the default internal source. This gives you full control over state transitions. +- **Emit interactions from a coroutine scope** (e.g. `TestScope().launch { }`) since `emit` is a suspend function. Do not use `LaunchedEffect` — that is a production Compose effect, not a test tool. +- **Assert the *result* of the interaction** (visual change, semantic change, enabled state), not the interaction itself. The interaction source is a test *driver*, not the assertion target. +- **Use this for screenshot tests too** — emit the interaction state, then capture the screenshot for a deterministic hover/press/focus visual. + +## Keyboard and focus + +For keyboard, TV, and desktop UI, drive navigation with the same input model users use (keys/D-pad), not clicks alone. Assert focused semantics, not colors or scale; reserve screenshots for visual focus treatment. + +Details—focus graph, `FocusRequester`, restoration, key handlers, and test patterns: [`compose-focus-navigation`](../compose-focus-navigation/SKILL.md). + +## Screenshot tests + +Use screenshots for visual contracts that semantics cannot prove: + +- Layout spacing/alignment. +- Themed colors, typography, elevation, shadows. +- Image composition, gradients, overlays. +- Focus highlight appearance. +- Loading skeletons or dense visual states. + +Keep screenshot state deterministic: + +- Use fixed state data. +- Freeze clocks or animation progress when possible. +- Replace network/image loading with fake or preview handlers. +- Avoid asserting dynamic text such as current time unless controlled. + +## Fake images and platform services + +When image content is irrelevant, fake the loader and assert the requested model if that is the behavior. The exact hook depends on your image library; a project helper might look like this: + +```kotlin +val requestedModels = mutableListOf() + +// Example helper, not a Compose API. +setContentWithFakeImageLoader { request -> + requestedModels += request.data + errorPainter() +} +``` + +When image appearance matters, provide a deterministic local painter/bitmap instead of network data. + +## Common mistakes + +| Mistake | Fix | +|---|---| +| Constructing full app graph to test an error row | Test plain UI with `state = Error` | +| Testing click behavior through a ViewModel mock | Pass a callback and assert it was invoked | +| Screenshot test for simple text presence | Use semantics assertion | +| Semantics test for padding/color/focus ring | Use screenshot test | +| Test tags everywhere | Prefer text/content description/role when stable | +| UI test depends on real image loading/network/time | Fake or freeze the source | +| Simulating hover/press/focus with mouse or touch events | Inject `MutableInteractionSource` and emit the interaction | +| Relying on the default `InteractionSource` in tests | Pass `MutableInteractionSource` so you can control state | +| TV/keyboard UI tested with `performClick` only | Use key input and focus assertions; see [compose-focus-navigation](../compose-focus-navigation/SKILL.md) | + +## Red flags during review + +- "This UI test is flaky because images load slowly." +- A test uses production DI for simple rendering. +- A screenshot has random dates, clocks, remote images, or live data. +- Assertions only check that a node exists after performing an action, not that the callback/state change happened. +- Focus behavior is visually inspected but not asserted. +- A test uses `performMouseInput` or touch injection to trigger hover/press states instead of `MutableInteractionSource.emit`. +- A composable accepts `interactionSource` but tests don't inject `MutableInteractionSource`. diff --git a/DeviceMasker-main/.agents/skills/compose/kotlin-coroutines-structured-concurrency/SKILL.md b/DeviceMasker-main/.agents/skills/compose/kotlin-coroutines-structured-concurrency/SKILL.md new file mode 100644 index 000000000..4934acdf4 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/compose/kotlin-coroutines-structured-concurrency/SKILL.md @@ -0,0 +1,440 @@ +--- +name: kotlin-coroutines-structured-concurrency +description: Use when writing or reviewing Kotlin code that stores CoroutineScope, launches from init/non-suspending APIs, calls runBlocking, or catches broad exceptions around suspend calls. +--- + +# Kotlin coroutines: structured concurrency + +## Core principle + +A well-structured coroutine is a self-contained unit of asynchronous work — single entry, single exit, scoped to a lifecycle known at the call site. + +**Scopes should usually be tied to the caller's lifecycle, not stored as a property on the callee.** A stored `CoroutineScope` is a strong review signal: the class must prove it owns cancellation, error reporting, restart behavior, and lifecycle. Most repositories, managers, use cases, and data sources cannot prove that, so they should expose `suspend` APIs instead. + +The fix is almost always the same: **make the API `suspend` and let the caller own the scope.** + +## When to use this skill + +You're writing or reviewing Kotlin code and you see any of these: + +- A class with `private val scope: CoroutineScope` (constructor param stored as a property) +- An `init { scope.launch { ... } }` block +- A non-suspending public function whose body is `scope.launch { ... }` +- `runBlocking { ... }` in suspend-capable application code, or in tests where `runTest` should apply +- `runCatching { suspendCall() }` or a `catch` on `Exception` / `Throwable` around a `suspend` call without rethrowing `CancellationException` +- A `catch (e: CancellationException)` (or equivalent) around suspension that does not rethrow + +## The silent-cancellation bug + +The reason an unowned `CoroutineScope` property is so dangerous: **once a scope is cancelled, every future `launch` on it silently completes as cancelled — no exception, no log, nothing.** The work just doesn't happen. This is one of the hardest coroutine bugs to diagnose, and it appears when a class holds a long-lived reference to a lifecycle it does not own. + +If APIs are `suspend`, this can't happen: the caller's scope is either alive (work runs) or the call site cancels (the caller knows). + +## Anti-patterns and fixes + +### 1. CoroutineScope stored as a property + +```kotlin +// ❌ BAD +@Inject +class UserRepository( + private val scope: CoroutineScope, + private val api: UserApi, +) { + fun refresh() { + scope.launch { _state.value = api.fetchUser() } + } +} + +// ✅ GOOD +@Inject +class UserRepository( + private val api: UserApi, +) { + suspend fun refresh(): User = api.fetchUser() +} +``` + +The repository no longer needs to know about coroutines at all. The caller (a ViewModel, a use case) decides on what scope, with what error handling, with what cancellation semantics. + +### 2. init-block launches + +```kotlin +// ❌ BAD: construction-time side effect, unbounded work +class UserSession(private val scope: CoroutineScope, private val api: Api) { + init { scope.launch { _user.value = api.load() } } +} +``` + +The constructor returns immediately. The caller can't `await` the load, can't see errors, can't cancel. The class is "alive" but its state is undefined. + +```kotlin +// ✅ GOOD: explicit bootstrap, caller owns the suspension +class UserSession(private val api: Api) { + private var _user: User? = null + val user: User get() = checkNotNull(_user) { "Call init() first" } + + suspend fun init() { _user = api.load() } +} +``` + +### 3. Fire-and-forget from non-UI classes + +A non-suspending public function on a **non-UI class** (repository, manager, use case, data source) that launches into a class-owned scope. The caller gets no result, no error, no cancellation, and no guarantee the work ever ran. + +```kotlin +// ❌ BAD — repository with stored scope and fire-and-forget public API +class AnalyticsClient(private val scope: CoroutineScope, private val api: Api) { + fun track(event: Event) { + scope.launch { api.send(event) } // caller has no idea what happens + } + fun signOut() { + scope.launch { api.signOut() } // silent failure if scope cancelled + } +} +``` + +```kotlin +// ✅ GOOD +class AnalyticsClient(private val api: Api) { + suspend fun track(event: Event) = api.send(event) + suspend fun signOut() = api.signOut() +} +``` + +#### Carve-out: the UI ↔ state-holder boundary + +UI frameworks are non-suspending. A Composable's `onClick`, a Fragment's `onKeyEvent`, an Activity's `onNewIntent` — none can `suspend`. The state holder (ViewModel, Decompose Component, feature model, etc. — anything whose role is to absorb UI events and hold UI state) **is** the boundary that translates one-shot UI events into asynchronous work bound to the UI lifecycle. That's its job. + +```kotlin +// ✅ GOOD — state holder absorbs a non-suspending UI event onto its scope +class FavouritesViewModel(private val repo: FavouritesRepository) : ViewModel() { + fun onToggleFavourite(item: Item) { + viewModelScope.launch { repo.toggleFavourite(item) } + } +} + +// in Compose: +ListItem(onClick = { viewModel.onToggleFavourite(item) }) +``` + +This is **not** the fire-and-forget anti-pattern. All three conditions must hold: + +1. **State holder for a UI surface** — a ViewModel, Decompose Component, feature model, or equivalent UI state holder. Not a repository, manager, use case, or data source. +2. **Lifecycle-bound scope** — `viewModelScope`, a Component's `coroutineScope` that's cancelled on destroy, a Composable's `rememberCoroutineScope()`. Not `AppScope`, not an injected long-lived scope, not an ad-hoc `CoroutineScope(...)`. +3. **Caller really is a UI event** — Composable callback, key handler, lifecycle hook. Not another business-logic class calling through the state holder. + +The repository / use case / data source layers underneath still expose `suspend` APIs. The state holder is the *only* layer where the non-suspending → suspending translation belongs. + +"It feels like a state holder" isn't enough. The question is "does the UI directly bind to this?" If no, the carve-out doesn't apply. + +### 4. Stored scopes that aren't injected + +The same anti-pattern, without an injected scope: + +```kotlin +// ❌ BAD — same problem, scope is constructed in-class instead of injected +class FooManager { + private val scope = MainScope() + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) +} +``` + +Lifecycle is now owned by nothing and lives forever. Replace with `suspend` APIs. + +The same is true if the instantiation is nested inside a function body — `fun foo() { CoroutineScope(...).launch { … } }` is just a stored scope with extra steps. Each call leaks a new uncancellable scope; bundling it into a `by lazy` property doesn't fix the underlying issue (the scope shouldn't exist at all). + +### 5. DI-bound singletons / initializers that launch + +A specific pattern that is hard to spot: a DI-bound class (`@SingleIn(AppScope)`, `@Singleton`, an `Initializer.initialize()`) launches a coroutine from its constructor / `init` block / `initialize()`. The launched work then has: + +- **A non-deterministic start time** — whenever the graph realizes the binding. Cold-start ordering is invisible. +- **No observable lifecycle.** Nothing else in the codebase can see whether it's running or has crashed. +- **No `stop()` / restart path.** If upstream enters a bad state, the loop is uncancellable. +- **No calling code to grep for.** Readers can't find "who starts this and when". + +§1 says scopes should be tied to the caller's lifecycle. The DI-bound variant violates this indirectly: the *scope* may be injected, but the *launch* is hidden inside construction — same effect, harder to see. + +```kotlin +// ❌ BAD — singleton boots work as a side effect of being constructed +@SingleIn(AppScope::class) +@Inject +class TokenRefresher( + @ForScope(AppScope::class) private val scope: CoroutineScope, + private val auth: AuthService, +) { + init { + scope.launch { + while (isActive) { + delay(5.minutes) + auth.refreshIfNeeded() + } + } + } +} + +// ❌ ALSO BAD — Initializer.initialize() that *launches*, not just registers +class TokenInvalidatorInitializer @Inject constructor( + @ForScope(AppScope::class) private val scope: CoroutineScope, + private val store: AuthStore, + private val invalidator: TokenInvalidator, +) : Initializer { + override fun initialize() { + scope.launch { store.tokenChanges.collect { invalidator.invalidate() } } + } +} +``` + +Both look like "application-scoped singletons", but the **When NOT to apply** carve-out is *not* permission to launch from `init` / `initialize()`. It's permission for a singleton to own a scope when its API is suspending. + +#### First ask: does this background-loop class need to exist at all? + +Most background-loop classes exist only because no one inverted the observation. Three answers, in order of preference: + +**Pattern 1 — invert into the consumer.** The class observes state forever to react when it changes. But *someone* mutates the state — sign-out flow, profile switch, flag-update handler. That mutation site is already in a coroutine context and is the natural place to do the work directly. + +```kotlin +// ✅ GOOD — no background loop, no scope, no class. The mutation site does the work. +class Authenticator( + private val authStore: AuthStore, + private val tokenInvalidator: TokenInvalidator, +) { + suspend fun signOut() { + authStore.clearTokens() + tokenInvalidator.invalidate() // direct call at the mutation site + } +} +``` + +The background-loop class is **deleted**. The work happens where the state changes. + +When this applies: the consumer of the state has a clear lifecycle (a use case, an Authenticator, a service handler) and can perform the reaction inline. + +**Pattern 2 — scheduled work.** Genuinely periodic or deferred. Use WorkManager / BGTaskScheduler. The enqueue is one-shot; make it suspending and call it once from an orchestrator that already runs at startup. + +**Pattern 3 — explicit named launch site.** Sometimes the consumer is a synchronous API with no observable lifecycle (e.g., OpenTelemetry's `Sampler.shouldSample(...)`, an AIDL stub fanout, a broadcast receiver bridge). The observation has to live somewhere coroutine-aware, but it must live at an *explicit named call site* — not in the class's own `init`. + +```kotlin +// ✅ GOOD — work is named; an explicit call site owns the launch +@SingleIn(AppScope::class) +class OtelConfigurableSampler(...) : Sampler { + @Volatile private var delegate: Sampler = ... + + suspend fun observeRate(featureFlags: FeatureFlags) { + featureFlags.observe(OTEL_SAMPLING_RATE).collect { rate -> + delegate = Sampler.traceIdRatioBased(rate.coerceIn(0.0, 1.0)) + } + } + + override fun shouldSample(...) = delegate.shouldSample(...) +} + +// wired explicitly at the OTel SDK init module: +applicationScope.launch { otelSampler.observeRate(featureFlags) } +``` + +When this applies: the consumer is a synchronous API that calls *into* you with no observable lifecycle. The launch can't be invertible, but it must still be visible at a named call site. + +#### Test for which pattern fits + +"Is the consumer's lifecycle observable to me?" + +- **Yes, and they're already in a coroutine context** → Pattern 1. Push the subscription into them; delete the background-loop class. +- **The work is periodic / deferred** → Pattern 2. Suspend enqueue called once. +- **No, they're a synchronous API with no observable lifecycle** → Pattern 3. Explicit launch site, not `init`. + +If a fourth answer seems to fit — e.g., "I want a `Bootable` interface that launches everything for me" — that's the same anti-pattern with an extra layer of abstraction. The whole point is that launches be *visible*; auto-discovery by interface defeats it. + +#### Initializers are still fine — *if they only register* + +The `Initializer` pattern is correct when `initialize()` *registers* a listener or hook. The bug is when `initialize()` *launches* a coroutine. + +```kotlin +// ✅ GOOD Initializer — registers a contributor, doesn't launch +class FavouritesContributorInitializer @Inject constructor( + private val registry: ContributorRegistry, + private val favouritesContributor: FavouritesContributor, +) : Initializer { + override fun initialize() { + registry.register(favouritesContributor) + } +} +``` + +**`Initializer.initialize()` must not `launch` a coroutine.** If yours does, it's a Pattern 1/2/3 candidate. + +#### Diagnostic for review + +- Where is the start moment defined? If "wherever DI realizes me", bad. +- Who can observe whether the work is running? If "no one", bad. +- Who can stop or restart it? If "no one", bad. +- Can a reader grep for the launch site? If no, bad. + +If the answers are "the consumer / the orchestrator / the named call site" — you're good. + +### 6. Swallowing `CancellationException` + +A `catch` clause around a `suspend` call that matches `CancellationException` — directly, or through `Exception` / `Throwable` — and doesn't rethrow usually turns cancellation into silent success. The parent coroutine thinks the child finished; the child keeps running (or its side effects do); the cancellation contract is broken. + +Same failure shape as §1's stored-scope bug, viewed from the other end: §1 hides the work *from* the caller's lifecycle; this hides cancellation *from* the work. + +```kotlin +// ❌ BAD — catches CancellationException, never rethrows +suspend fun fetch() { + try { + api.load() + } catch (e: Exception) { // matches CancellationException too + logger.warn("load failed", e) + } +} + +// ❌ ALSO BAD — runCatching has the same problem +suspend fun fetch() { + runCatching { api.load() } + .onFailure { logger.warn("load failed", it) } +} +``` + +The acceptable shapes: + +```kotlin +// ✅ Separate catch first +try { api.load() } +catch (e: CancellationException) { throw e } +catch (e: Exception) { logger.warn("load failed", e) } + +// ✅ Conditional rethrow inside the broad catch +try { api.load() } +catch (e: Exception) { + if (e is CancellationException) throw e + logger.warn("load failed", e) +} + +// ✅ ensureActive() — good when the catch handles ordinary failures and you only need +// to rethrow if the current coroutine is cancelled +try { api.load() } +catch (e: Exception) { + currentCoroutineContext().ensureActive() + logger.warn("load failed", e) +} + +// ✅ runCatching with explicit guard +runCatching { api.load() } + .onFailure { + if (it is CancellationException) throw it + logger.warn("load failed", it) + } + +// ✅ runCatching terminated with getOrThrow (cancellation flows back out) +runCatching { api.load() }.getOrThrow() +``` + +The trigger is "a suspend call inside the `try`", not "the enclosing function is declared `suspend`". This applies inside any suspending body — `suspend fun`, a `launch { … }` lambda, a Flow `collect { … }`, etc. + +The common carve-out is an intentionally local timeout: catching `TimeoutCancellationException` from your own `withTimeout` and converting it to a domain result can be correct. Keep that catch narrow and close to the timeout. Do not use it as permission to swallow arbitrary cancellation. + +Catching a non-cancellation subtype (`IOException`, your own exception types) is fine — they don't extend `CancellationException`. + +### 7. `runBlocking` + +`runBlocking` parks the current thread until the lambda finishes. Inside suspend-capable or lifecycle-scoped application paths it is wrong: a thread that meant to be async is now blocked, structured concurrency is broken, and any cancellation upstream has no effect. It is the "callee makes a structural decision for the caller" anti-pattern at its most direct. + +```kotlin +// ❌ BAD — bridging to suspend by blocking the calling thread +fun saveUser(user: User) { + runBlocking { repository.save(user) } +} +``` + +Three fixes, by context: + +**Suspend-capable application code** — make the function `suspend`: + +```kotlin +// ✅ GOOD +suspend fun saveUser(user: User) = repository.save(user) +``` + +If the immediate caller can't suspend either (a non-suspending UI callback, a `BroadcastReceiver` hook), use the existing lifecycle-bound scope at the boundary — see §3's UI ↔ state-holder carve-out. The fix is at the boundary, not inside `saveUser`. + +Legitimate blocking boundaries exist: `main` in a CLI tool, Java interop APIs that must return synchronously, framework callbacks with no suspending alternative, and migration shims. Keep `runBlocking` at that outer boundary, keep the body small, and call suspending code immediately. + +**Tests** — use `runTest`: + +```kotlin +// ❌ BAD — real time, slow tests, no virtual delay +@Test fun loadsUser() = runBlocking { + assertThat(repository.load().name).isEqualTo("Alice") +} + +// ✅ GOOD +@Test fun loadsUser() = runTest { + assertThat(repository.load().name).isEqualTo("Alice") +} +``` + +`runTest` gives you virtual time (`delay()` returns immediately), `TestDispatcher` integration, and proper coroutine cleanup. Real-time `runBlocking` in tests makes them slow and flaky. + +**`ContentProvider` carve-out** — Android's `ContentProvider` methods (`query`, `insert`, `update`, `delete`, `onCreate`, `call`) are synchronous from outside the process. There is no way to suspend them. Inside *member functions* of a `ContentProvider` subclass (direct or indirect — not companion objects), `runBlocking` is the unavoidable bridge. Keep the body as short as possible and call into suspending code immediately: + +```kotlin +// ✅ Acceptable in ContentProvider members only +class MyProvider : ContentProvider() { + override fun query(...): Cursor? = runBlocking { dao.query(...) } +} +``` + +This carve-out is for `android.content.ContentProvider` subclasses *only*. "It's like a `ContentProvider`" doesn't apply, and a `runBlocking` in a `ContentProvider`'s companion object is still a regular violation — the helper isn't part of the framework's synchronous surface. + +## Quick reference + +| Symptom | Anti-pattern | Fix | +|---|---|---| +| Class has `private val scope: CoroutineScope` | Stored scope on the callee | Remove. Make public APIs `suspend`. | +| `init { scope.launch { ... } }` | Construction-time launch | Move to `suspend fun init()` / `login()` | +| `fun foo() { scope.launch { ... } }` on a repository/manager/use case | Fire-and-forget from non-UI class | `suspend fun foo()`, let UI state holder pick the scope | +| `fun onClick() { viewModelScope.launch { ... } }` on a state holder, called from UI | UI ↔ state-holder boundary — fine | Keep as-is (see §3 carve-out) | +| `private val scope = MainScope()` | Internally-constructed stored scope | Same — remove, make APIs `suspend` | +| `@SingleIn(AppScope) class X(scope) { init { scope.launch { … } } }` | DI-bound opaque launch (§5) | Expose `suspend fun run()`, launch from startup orchestrator | +| `class Y : Initializer { override fun initialize() { scope.launch { … } } }` | Initializer that launches, not registers (§5) | Same — `suspend fun run()`, orchestrator owns lifecycle | +| `try { suspendCall() } catch (e: Exception\|Throwable\|CancellationException) { … }` with no rethrow | Swallowed cancellation (§6) | Prefer `catch (e: CancellationException) { throw e }`; use `ensureActive()` only when that matches the intent | +| `runCatching { suspendCall() }.onFailure { … }` with no cancellation guard | Same shape as above (§6) | Add `if (it is CancellationException) throw it`, or terminate with `.getOrThrow()` | +| `runBlocking { … }` inside suspend-capable app code | Thread-blocking bridge (§7) | Make caller `suspend`; or use a lifecycle scope at the boundary | +| `runBlocking { … }` in a test | Same — real-time bridging (§7) | Use `runTest { … }` | +| `runBlocking { … }` inside a `ContentProvider.query`/`insert`/… member | Carve-out (§7) | Acceptable; keep the body minimal | + +## Refactoring guidance + +Removing an existing offender: + +1. **Start at the leaf.** Pick the class farthest from any UI — usually a repository or data source. Its public surface should be the easiest to convert. +2. **Convert public functions to `suspend`** one at a time. The compiler will surface every caller. +3. **At each caller, choose the scope deliberately:** `viewModelScope`, `lifecycleScope`, `coroutineScope { }`, or an explicit job. This is the choice that was missing before. +4. **Delete the `CoroutineScope` constructor parameter** once nothing uses it. Remove the injection binding. + +Don't try to fix every class in one MR. Removing an anti-pattern is incremental work. + +## When NOT to apply + +- **UI state holders absorbing UI events.** A ViewModel/Component/feature model with `fun onClick(...) { viewModelScope.launch { ... } }` is correct — that's the boundary the framework needs. See §3 carve-out. +- **Lifecycle owners with explicit cancellation and error policy.** Actors/services, app infrastructure, or application-scoped singletons may own a scope when they expose clear `close`/`cancel`/restart behavior or otherwise map directly to an application lifecycle. Inject `Application.applicationScope` explicitly rather than creating one ad-hoc. **This is not permission to launch from `init` / `initialize()`** — see §5. +- **Already-suspending APIs** don't need any of this work. +- **Tests** sometimes use `TestScope` as a deliberate ambient scope — that's a different pattern with explicit virtual-time control. + +## Red flags during review + +These thoughts mean the anti-pattern is back: + +| Thought | Reality | +|---|---| +| "I'll just add a `CoroutineExceptionHandler` to the scope" | The problem isn't error handling. The problem is the scope shouldn't exist. | +| "I need to launch from `init` so the data's ready when consumers arrive" | Consumers reading state that isn't ready is the bug. Use phasing. | +| "The caller doesn't want to deal with `suspend`" | Then the caller chooses fire-and-forget at their scope. Don't decide for them. | +| "It's just a small fire-and-forget call" | Silent cancellation makes every fire-and-forget a potential silent failure. | +| "We caught and logged the exception, so we're fine" | Did the catch rethrow `CancellationException`? If no, the coroutine is silently un-cancelled. (§6) | +| "It's just one `runBlocking`, in a non-critical path" | Every `runBlocking` asserts the caller has no async option. If they do, it's the wrong primitive. (§7) | +| "Tests are simpler with `runBlocking`" | They run in real time, can't fast-forward `delay`, and lose `TestDispatcher` semantics. Use `runTest`. (§7) | + +## Related + +- [`kotlin-flow-state-event-modeling`](../kotlin-flow-state-event-modeling/SKILL.md) — `StateFlow`, `SharedFlow`, `Channel`, `stateIn`, one-shot events, and related modeling. diff --git a/DeviceMasker-main/.agents/skills/compose/kotlin-flow-state-event-modeling/SKILL.md b/DeviceMasker-main/.agents/skills/compose/kotlin-flow-state-event-modeling/SKILL.md new file mode 100644 index 000000000..26d3d055b --- /dev/null +++ b/DeviceMasker-main/.agents/skills/compose/kotlin-flow-state-event-modeling/SKILL.md @@ -0,0 +1,183 @@ +--- +name: kotlin-flow-state-event-modeling +description: Use when writing or reviewing Kotlin Flow state and event APIs with StateFlow, MutableStateFlow.update, SharedFlow, Channel, stateIn, SharingStarted, .value, receiveAsFlow, one-shot events, or sentinel initial values. +--- + +# Kotlin Flow: state and event modeling + +## Core principle + +**Pick the primitive that matches replay, fan-out, and synchronous-read requirements.** `StateFlow`, `SharedFlow`, `Channel`-backed flows, and cold `Flow` differ in buffering, who sees each emission, and whether `.value` exists. Wrong choices drop events, leak sharing coroutines, or force fake domain sentinels into state. + +## When to use this skill + +You're writing or reviewing Kotlin code involving: + +- `MutableStateFlow(SomeSentinel)` — `NoUser`, `Empty`, `Loading`, etc. — because the real value is async +- `.stateIn(...)` called inside a function rather than assigned to a property +- `SharingStarted.WhileSubscribed(...)` on a flow whose `.value` is read synchronously and must stay fresh +- `MutableSharedFlow` for navigation events, snackbars, or other one-shot emissions where loss would be a bug +- `.map { }` on a `StateFlow` when consumers still need synchronous `.value` +- `MutableStateFlow.value = _state.value.copy(...)` or update code that builds expensive objects inside `update { ... }` + +## SharedFlow for single-consumer fire-once events + +`SharedFlow` defaults have no replay buffer. If nothing is collecting at the exact instant of emission, the event is gone. For a **single UI consumer** handling exactly-once events such as navigation or snackbars, a buffered `Channel` exposed as a `Flow` often matches the semantics better: + +```kotlin +// ❌ BAD +private val _navEvents = MutableSharedFlow() +val navEvents: SharedFlow = _navEvents.asSharedFlow() + +// ✅ GOOD +private val _navEvents = Channel(Channel.BUFFERED) +val navEvents: Flow = _navEvents.receiveAsFlow() +``` + +`Channel.receiveAsFlow()` is **fan-out, not broadcast**: with multiple collectors, each event is delivered to **one** collector. `Channel.BUFFERED` is bounded, so sends can suspend and `trySend` can fail. If multiple observers must all see the same event, use explicit state, durable storage, or a deliberately configured `SharedFlow` instead. + +## StateFlow polluted with invalid sentinel defaults + +`StateFlow` forces an initial value. When the real value is async, developers sometimes invent fake domain values — `NoUser`, `EmptyUser`, placeholder IDs — and every consumer is forced to treat that sentinel as real data. + +```kotlin +// ❌ BAD — sentinel leaks into the type +class UserSession(private val db: Db) { + private val _user = MutableStateFlow(NoUser) + val user: StateFlow = _user.asStateFlow() + init { scope.launch { _user.value = db.load() } } +} +``` + +One fix is **phasing**: don't expose the `StateFlow` until the real value exists. + +```kotlin +// ✅ GOOD — bootstrap suspends; observers only see real users +class UserSession(private val db: Db) { + private var _user: MutableStateFlow? = null + val user: StateFlow + get() = checkNotNull(_user) { "Call login() first" } + + suspend fun login() { + _user = MutableStateFlow(db.load()) + } +} +``` + +If absence, loading, or error is a real state, model it explicitly (`User?`, `sealed interface UserUiState`, `Result`, etc.). The bug is a fake domain value masquerading as real data, not every initial value. + +## Mutate MutableStateFlow with `update { ... }` + +Prefer `MutableStateFlow.update { current -> ... }` over reading `.value` and writing it back. `update` applies the transform atomically against the latest state, which avoids lost updates when multiple coroutines mutate the same state. + +```kotlin +// BAD — read/modify/write can lose concurrent updates. +_state.value = _state.value.copy( + selectedId = id, + details = details, +) + +// GOOD — transform starts from the latest state. +_state.update { current -> + current.copy( + selectedId = id, + details = details, + ) +} +``` + +Keep object creation outside the `update` block unless it needs the current state. The update lambda can be retried, so expensive work or side effects inside it may run more than once: + +```kotlin +// GOOD — details does not depend on current state, so build it once. +val details = Details.from(response) +_state.update { current -> + current.copy(details = details) +} + +// GOOD — derived value depends on current state, so compute it inside. +_state.update { current -> + val nextItems = current.items.replaceById(updatedItem) + current.copy(items = nextItems) +} +``` + +The block should be a pure, fast state transformation: no network calls, database writes, logging side effects, random IDs, or time reads unless those values were captured before the block. + +## `stateIn()` inside a function + +```kotlin +// ❌ BAD — new sharing coroutine every call +fun getPreferences(): StateFlow = + repo.prefsFlow.stateIn(scope, SharingStarted.Eagerly, Prefs.Default) +``` + +Every call to `getPreferences()` launches a fresh coroutine on `scope` that never completes. Performance dies fast under repeated reads. + +```kotlin +// ✅ GOOD — one shared instance, computed once +val preferences: StateFlow = + repo.prefsFlow.stateIn(viewModelScope, SharingStarted.Eagerly, Prefs.Default) +``` + +## `WhileSubscribed` with synchronous `.value` + +`SharingStarted.WhileSubscribed(timeout)` disconnects the upstream when there are no active collectors. While disconnected, `.value` returns the last cached value, which may be stale or still the initial value. + +**Rule:** if `.value` must be fresh or initialized without an active collector, use `SharingStarted.Eagerly` or explicit initialization. `WhileSubscribed` is fine when stale/cached values are acceptable and consumers primarily collect asynchronously. + +## `.map` on `StateFlow` loses `.value` + +```kotlin +// ❌ BAD — `name.value` won't compile; it's now a plain Flow +val name: Flow = userState.map { it.name } +``` + +If you need synchronous `.value`, terminate the chain with `.stateIn(...)`: + +```kotlin +// ✅ GOOD +val name: StateFlow = userState + .map { it.name } + .stateIn(viewModelScope, SharingStarted.Eagerly, userState.value.name) +``` + +Community “derived state flow” utilities run the transform on every `.value` read — only acceptable for fast, idempotent transforms. Default to `.stateIn(...)`. + +## Decision: which Flow type? + +| Need | Primitive | +|------|-----------| +| State that always has a value, read by both async collectors **and** synchronous code | `StateFlow`, often with `SharingStarted.Eagerly` when `.value` matters | +| Hot stream, multiple subscribers, **no** requirement for synchronous `.value` | `SharedFlow` | +| Discrete events for **one** consumer, exactly-once handoff | Consider `Channel(BUFFERED).receiveAsFlow()` | +| Cold stream, one consumer per collection | Plain `Flow` | + +If you're tempted to reach for `SharedFlow`, ask: would dropping an emission be a bug, and how many consumers must see it? If one consumer must handle it exactly once, a `Channel` may fit. If every observer must see it, model durable state or configure a broadcast stream deliberately. + +## Quick reference + +| Symptom | Problem | Fix | +|---------|---------|-----| +| `MutableStateFlow(FakeDomainValue)` | Invalid placeholder default | Model absence explicitly or use phase initialization | +| `MutableSharedFlow` for single-consumer nav/snackbar | Lossy default event stream | Consider `Channel(BUFFERED).receiveAsFlow()` | +| `fun foo() = flow.stateIn(...)` | Per-call sharing coroutine | Make it a `val` / shared instance | +| `WhileSubscribed` + `.value` must be fresh/initialized | Stale or initial data | `SharingStarted.Eagerly` or explicit initialization | +| `stateFlow.map { ... }` consumed as state | Lost `.value` | Terminate with `.stateIn(...)` | +| `_state.value = _state.value.copy(...)` | Non-atomic read/modify/write | `_state.update { it.copy(...) }` | +| Expensive object creation inside `update { ... }` that doesn't use current state | Work can repeat if update retries | Build before `update`; keep only current-state transforms inside | + +## Red flags during review + +| Thought | Reality | +|---------|---------| +| "We need `SharedFlow` because there are multiple subscribers" | Multiple subscribers change the semantics. `Channel.receiveAsFlow()` is not broadcast; choose the event model deliberately. | +| "We'll use `WhileSubscribed` to save resources" | Only if stale/initial `.value` reads are acceptable. Verify before applying. | +| "I'll use a sentinel until real data loads" | Consumers treat it as real domain; prefer explicit UI/state modeling or phasing. | +| "I'll construct the new object inside `update` because it's convenient" | The lambda may retry. Construct outside unless it depends on the current state. | + +## Related + +- [`kotlin-coroutines-structured-concurrency`](../kotlin-coroutines-structured-concurrency/SKILL.md) — scope ownership, init launches, fire-and-forget boundaries, cancellation, `runBlocking` +- [`compose-side-effects`](../compose-side-effects/SKILL.md) — collecting event flows and wiring side effects in Compose +- [`compose-state-holder-ui-split`](../compose-state-holder-ui-split/SKILL.md) — where state holders expose flows to UI diff --git a/DeviceMasker-main/.agents/skills/compose/kotlin-multiplatform-expect-actual/SKILL.md b/DeviceMasker-main/.agents/skills/compose/kotlin-multiplatform-expect-actual/SKILL.md new file mode 100644 index 000000000..4364acc8c --- /dev/null +++ b/DeviceMasker-main/.agents/skills/compose/kotlin-multiplatform-expect-actual/SKILL.md @@ -0,0 +1,123 @@ +--- +name: kotlin-multiplatform-expect-actual +description: Use when designing Kotlin Multiplatform expect/actual or interface boundaries for platform services, native SDKs, source sets, Compose Multiplatform UI, permissions, files, settings, sensors, or platform interop. +--- + +# Kotlin Multiplatform: expect/actual boundaries + +## Core principle + +Keep common APIs semantic and stable. Put platform mechanics behind small `expect`/`actual` declarations or interfaces, and keep Android/iOS/Desktop details out of `commonMain`. + +## When to use this skill + +Use this when common code needs: + +- Permissions, settings, intents, share sheets, deep links, haptics, biometrics, or clipboard. +- Files, paths, clocks, locale, network reachability, sensors, crypto, media, maps, camera, native SDKs, or platform services. +- Native platform views, controllers, or Compose Multiplatform interop. +- Different implementation details on Android, iOS, Desktop, or Wasm while preserving one shared call site. +- A decision between `expect/actual`, dependency injection, interfaces, or separate platform code. + +## Choose the boundary + +| Situation | Prefer | +|---|---| +| Simple compile-time platform specialization | `expect`/`actual` function, value, typealias, or leaf composable | +| Implementation needs injected dependencies, lifecycle ownership, runtime choice, or test fakes | Common interface plus platform binding | +| UI is mostly shared, one leaf differs | Common composable calling an `expect` leaf | +| Entire screen differs by platform | Separate platform screens behind a common navigation contract | +| Only constants/resources differ | Common API exposing semantic values, actual values per platform | + +## Keep common APIs semantic + +Common code should describe what the product needs, not how the platform does it: + +```kotlin +// GOOD: common API is semantic +expect fun currentRegion(): Region +``` + +```kotlin +// BAD: common API leaks Android implementation +expect fun currentRegionFromAndroidLocale(context: Context): Region +``` + +The Android actual can use `Locale` APIs. The iOS actual can use Foundation APIs. Callers should not know. + +## Keep actuals thin + +Actual implementations should translate the semantic API into platform calls. If the operation needs an Activity, view controller, lifecycle owner, DI, or fakes, prefer an interface supplied by platform code instead of an `expect class`: + +```kotlin +// commonMain +interface ShareSheet { + suspend fun shareText(text: String) +} +``` + +```kotlin +// androidMain +class AndroidShareSheet( + private val activity: Activity, +) : ShareSheet { + override suspend fun shareText(text: String) { + val intent = Intent(Intent.ACTION_SEND) + .setType("text/plain") + .putExtra(Intent.EXTRA_TEXT, text) + activity.startActivity(Intent.createChooser(intent, null)) + } +} +``` + +The Android implementation is explicitly Activity-owned. A generic `Context` may need `FLAG_ACTIVITY_NEW_TASK` and usually hides the UI lifecycle requirement. Define what `suspend` means: for many platform UI actions it means "the sheet was launched", not "the user completed sharing." + +If the actual starts accumulating business rules, move those rules back to common code and leave only platform translation in the actual. + +## Prefer interfaces when tests or DI matter + +Use `expect/actual` for simple compile-time platform APIs. Use interfaces when common code needs fakes, multiple implementations, runtime selection, or lifecycle ownership: + +```kotlin +interface Clipboard { + suspend fun setText(text: String) +} +``` + +Platform modules bind `Clipboard` to Android/iOS implementations. Common tests use a fake. + +## Compose-specific guidance + +- Keep platform-specific Composables at leaf nodes. +- Pass `Modifier` through every expected Composable that emits UI. +- Avoid platform types in `commonMain` signatures (`Context`, `Activity`, Android resource IDs, `Uri`, `Bundle`, `UIViewController`, `NSBundle`, platform permission enums, etc.). +- If native view lifecycle matters, hide it inside the platform actual and use the right interop container (`AndroidView`, `UIKitView`, etc.). +- Do not launch platform work directly from a Composable body. Use `remember`, `LaunchedEffect`, `DisposableEffect`, and stable keys inside actual Composables just as you would in common Compose code. +- Make previews/tests use common plain UI composables with fake platform services where possible. + +## Common mistakes + +| Mistake | Fix | +|---|---| +| `commonMain` API exposes Android/iOS types | Replace with semantic common types | +| `expect` function has parameters for one platform only | Move those details into the actual | +| Business branching duplicated in each actual | Move business rules to common code | +| One huge `Platform` expect object | Split by capability: `Clipboard`, `ShareSheet`, `Haptics` | +| Platform UI leaks high in the tree | Push platform-specific Composable to a leaf | +| No fakeable boundary for common tests | Use an interface instead of direct `expect` call | + +## Red flags during review + +- Common code imports platform packages. +- An actual implementation knows product state, navigation decisions, or domain rules. +- A platform API name appears in a common function name. +- Adding a third platform would require changing common callers. +- Tests need Android/iOS runtime just to verify common business behavior. + +## Related (Compose / shared UI) + +Stay focused on platform boundaries in this skill; wire shared UI like any other Compose target: + +- [`compose-state-holder-ui-split`](../compose-state-holder-ui-split/SKILL.md) — shared plain UI composables vs state-holder wiring. +- [`compose-side-effects`](../compose-side-effects/SKILL.md) — effect keys and cleanup in actual composables (`LaunchedEffect`, `DisposableEffect`, etc.). +- [`compose-modifier-and-layout-style`](../compose-modifier-and-layout-style/SKILL.md) and [`compose-slot-api-pattern`](../compose-slot-api-pattern/SKILL.md) — reusable shared Compose APIs (modifiers, slots). diff --git a/DeviceMasker-main/.agents/skills/compose/kotlin-types-value-class/SKILL.md b/DeviceMasker-main/.agents/skills/compose/kotlin-types-value-class/SKILL.md new file mode 100644 index 000000000..4fa74a959 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/compose/kotlin-types-value-class/SKILL.md @@ -0,0 +1,119 @@ +--- +name: kotlin-types-value-class +description: Use when writing or reviewing Kotlin type declarations to choose @JvmInline value class over data class where appropriate, including Compose stability implications. +--- + +# Kotlin value class vs data class + +## Core principle + +Prefer `@JvmInline value class` for single-field types that carry domain meaning. Data classes are for aggregating multiple fields. A value class gives you type safety (you can't mix up `UserId` and `String`) without the allocation overhead of a data class. + +## When to use this skill + +- Writing a new Kotlin type that wraps a single value +- Reviewing a data class that has only one property +- Seeing primitive types (`String`, `Long`, `Int`, etc.) used where a domain type would prevent misuse +- Compose compiler reports showing unstable parameters that could be value classes + +## Decision flow + +| Situation | Prefer | +|---|---| +| Single field + domain-meaningful (`UserId`, `EmailAddress`, `Percentage`) | `@JvmInline value class` | +| Single field + no domain meaning (just grouping) | Type alias or keep the primitive | +| Multiple fields | Data class | +| Needs custom `equals`/`hashCode`/`toString` beyond the wrapped value | Data class (value classes delegate to the underlying type) | +| Used as a generic type argument or nullable in hot paths | Data class or primitive (autoboxing cost) | + +```kotlin +// GOOD: domain-meaningful single field +@JvmInline value class UserId(val value: String) +@JvmInline value class EmailAddress(val value: String) +@JvmInline value class Percentage(val value: Float) + +// BAD: data class wrapping a single field +data class UserId(val value: String) // unnecessary allocation +data class EmailAddress(val value: String) // type safety without the overhead is available + +// BAD: value class with no domain meaning +@JvmInline value class Wrapper(val value: String) // just use the String, or a type alias + +// BAD: value class needing custom equality +@JvmInline value class CaseInsensitiveString(val value: String) +// value class equals delegates to String equals, which IS case-sensitive +// Use a data class if you need different equality semantics +``` + +## Compose stability + +`@JvmInline value class` is treated as `Stable` by the Compose compiler when its underlying type is stable (primitives, `String`, and other stable types). This means: + +- Value classes passed as composable parameters avoid "unstable parameter" warnings +- No need for `@Immutable` annotations at Compose boundaries when wrapping primitives or strings +- Replacing single-field data classes with value classes at UI boundaries improves skippability + +```kotlin +// Before: data class wrapping a single field +data class UiState(val userId: String) // works, but allocates a wrapper object + +// After: value class is stable and zero-allocation at runtime +@JvmInline value class UserId(val value: String) +data class UiState(val userId: UserId) +``` + +## Gotchas + +- **Autoboxing**: Value classes are unboxed at compile time but boxed (allocated) when used as nullable (`UserId?`), generic type arguments (`List`), or vararg parameters. In hot paths these allocations matter; in most code they don't. +- **No backing fields**: You cannot use `init` blocks, `lateinit`, or delegated properties like `by lazy`. The class body is extremely constrained — only the single constructor parameter exists. +- **No data-class conveniences**: No `copy()`, no `component1()` for destructuring, and no way to customize `toString()`. If you need any of these, use a data class. +- **No custom equals/hashCode/toString**: These always delegate to the underlying type. Need custom equality → use a data class. +- **when exhaustiveness**: Sealed hierarchies of value classes work differently than data class hierarchies. Test `when` branches carefully. +- **Serialization semantics**: With kotlinx.serialization, a `@Serializable data class A(val value: String)` serializes as `{"value":"..."}`, but a `@Serializable value class A(val value: String)` serializes as the underlying value (`"..."`). Replacing a single-field data class with a value class is a breaking change for your API/JSON contract. +- **Serialization**: Some serialization frameworks need explicit support for value classes (e.g., kotlinx.serialization's `@Serializable` works, but Jackson may need configuration). +- **Interoperability**: From Java, value classes appear as their underlying type. Java callers bypass the type-safety wrapper. +- **Reflection and runtime erasure**: When passed as `Any` or used in generic contexts, value classes box into a synthetic wrapper class. Java reflection sees mangled method signatures, and frameworks that rely on raw runtime types (some ORMs, DI containers, or serializers) may see the underlying type rather than the value class. + +## Packing multiple values + +A value class can only declare one field, but Compose provides `packFloats`, `packInts`, and matching `unpack*` functions in `androidx.compose.ui.util` to store multiple primitives in a single `Long`. This lets you represent composite values (e.g., a 2D point, size, or padding) as a zero-allocation value class instead of a multi-field data class. + +```kotlin +@JvmInline value class Offset(val packedValue: Long) + +fun Offset(x: Float, y: Float): Offset = Offset(packFloats(x, y)) +val Offset.x: Float get() = unpackFloat1(packedValue) +val Offset.y: Float get() = unpackFloat2(packedValue) +``` + +- **Only use this in performance-critical paths** — manual bit-packing is error-prone. A data class is simpler and safer for most UI types. +- **Available in `androidx.compose.ui.util`** — `packFloats`, `packInts`, `unpackFloat1`, `unpackFloat2`, `unpackInt1`, `unpackInt2`. + +## Common mistakes + +| Mistake | Fix | +|---|---| +| Data class wrapping a single domain field | Replace with `@JvmInline value class` | +| Value class with no domain meaning (just a wrapper) | Use a type alias or the primitive directly | +| Value class needing custom equality | Use a data class instead | +| Value class as generic type argument in hot path | Accept autoboxing cost or use the primitive | +| `@Immutable` annotation on a type that could be a value class | Replace with value class — it's Stable by default | +| Forgetting `@JvmInline` annotation | Always pair `value class` with `@JvmInline` for single-field classes | + +## Red flags during review + +- A data class with exactly one property +- A `String`, `Long`, or `Int` used where different values should not be interchangeable (e.g., `fun transfer(from: String, to: String, amount: Long)`) +- An `@Immutable` annotation on a single-field wrapper +- A type alias used for domain distinction where value-class semantics are needed (type aliases are type-erased, no runtime protection) + +## When NOT to apply + +- The type needs multiple fields → data class +- The type needs custom `equals`/`hashCode`/`toString` → data class +- The type is used heavily as a nullable or generic in performance-critical code → measure autoboxing cost first +- The project does not need the type-safety distinction → a type alias or primitive is sufficient + +## Related + +- [`compose-stability-diagnostics`](../compose-stability-diagnostics/SKILL.md) — diagnose unstable Compose parameters; value classes are one fix diff --git a/DeviceMasker-main/.agents/skills/device-masker-research/SKILL.md b/DeviceMasker-main/.agents/skills/device-masker-research/SKILL.md new file mode 100644 index 000000000..a7d42d043 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/device-masker-research/SKILL.md @@ -0,0 +1,92 @@ +--- +name: device-masker-research +description: Source-backed research for the Device Masker Android LSPosed/libxposed project that may write exactly one internal research report file and must not modify any other project files. Use when researching Android platform behavior, Android 16 compatibility, 16 KB page-size support, proc maps hardening, Kotlin, Gradle, AGP, Compose, Material, R8, LSPosed/libxposed/Xposed APIs, target-app detection behavior, release notes, breaking changes, upstream GitHub issues, new APIs, or any source-backed investigation that needs a cited report. +--- + +# Device Masker Research + +Use this skill to turn a research question into a current, cited, project-aware research report. + +## Write Boundary + +Write exactly one Markdown report file only. Use this path shape by default: + +```text +docs/internal/reports/active/research/YYYY-MM-DD/YYYY-MM-DD-topic-kebab-case.md +``` + +Use `closed/` only when the user explicitly asks for a final/closed/historical report or the report is clearly superseded/completed. + +Do not create, edit, move, rename, or delete any other project file or folder. Do not write logs, raw evidence files, Memory Bank updates, docs, source changes, config changes, build artifacts, commits, branches, tags, or pull requests. If the parent date folder is missing, create only the minimum parent folder required for the single report path. + +If research implies project changes, record the recommended edits inside the report instead of applying them. + +## Source Selection + +Use sources by authority and topic: + +- Google Developer Knowledge MCP: Android, Google APIs, Play, Firebase, Material, web.dev, Google Cloud, and official Google developer docs. +- Context7 MCP: non-Google library/framework/API docs such as Kotlin, Gradle-adjacent APIs, Compose libraries not covered by Google, and other current API references. Resolve the library ID first. +- Web search/browser: latest release notes, changelogs, breaking changes, upstream GitHub repositories, source code, issues, pull requests, and docs missing from MCP coverage. +- Project-local sources to inspect only: code, Memory Bank, docs, existing internal reports, existing build logs, existing device logs, existing verifier evidence, and public validation artifacts. + +Prefer primary sources. Use secondary sources only for orientation, then verify against official docs, upstream source, release notes, or existing project evidence. + +## Research Workflow + +1. Define the exact research question, affected modules, and decision the report should support. +2. Read required project context before external research. +3. Gather current external sources with the source-selection rules above. +4. Compare upstream facts against Device Masker constraints and existing project evidence. +5. Separate verified facts, inferences, risks, and unknowns. Do not mix them. +6. Write one comprehensive report file at the approved report path. +7. If project docs or Memory Bank should change, list exact recommended edits instead of applying them. + +## Report Shape + +Use this section order unless the user asks for a smaller answer: + +- Executive summary +- Research question and scope +- Source inventory with links or local paths +- Verified facts +- Source-backed findings +- Inferences +- Project impact +- Compatibility risks and edge cases +- Unknowns and gaps +- Recommendations +- Suggested next tasks +- Report file path +- Write boundary confirmation + +## Quality Bar + +Every non-trivial report must include: + +- Clear distinction between official docs, library docs, release notes/changelogs, GitHub/source/issues, and project-local evidence. +- Verified facts with citations. +- Inferences explicitly labeled as inferences. +- Project impact for `:app`, `:common`, `:xposed`, `:verifier`, validation, release/R8, or docs as relevant. +- Compatibility risks, edge cases, unknowns, and recommended next tasks. + +Do not claim Android physical-device stability from emulator-only evidence. Do not claim hook success from app launch, app-side service connection, or configuration presence alone. + +## Combined Research And Review Requests + +If the user asks for research and review/audit together: + +- Still write exactly one report file. +- Classify by the primary output: use `research/` when external source facts drive the answer; use the review/audit workflow when repository findings drive the answer. +- Include clear `Research Inputs` and `Review Findings` sections when both outputs are substantial. + +For purely local code review with no external research, use the review/audit workflow instead of this skill. + +## Final Response + +End with: + +- Key sources used, especially web/GitHub sources. +- The report file path. +- Confirmation that only the report file was written and no other files were edited, created, moved, committed, or pushed. +- Any remaining unknowns or validation gaps. diff --git a/DeviceMasker-main/.agents/skills/device-masker-research/agents/openai.yaml b/DeviceMasker-main/.agents/skills/device-masker-research/agents/openai.yaml new file mode 100644 index 000000000..867cf4deb --- /dev/null +++ b/DeviceMasker-main/.agents/skills/device-masker-research/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Device Masker Research" + short_description: "Device Masker research report" + default_prompt: "Use $device-masker-research to research Android 16 compatibility for Device Masker and write one source-backed internal report file only." diff --git a/DeviceMasker-main/.agents/skills/device-masker-research/references/report-template.md b/DeviceMasker-main/.agents/skills/device-masker-research/references/report-template.md new file mode 100644 index 000000000..d9269e401 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/device-masker-research/references/report-template.md @@ -0,0 +1,91 @@ +# Research Report Template + +Use this template for the single Device Masker research report file written by the skill. + +```markdown +# YYYY-MM-DD Topic Title + +Date: YYYY-MM-DD +Mode: Research report +Report path: docs/internal/reports/active/research/YYYY-MM-DD/YYYY-MM-DD-topic-kebab-case.md + +## Executive Summary + +Summarize the answer, decision impact, and highest-risk unknowns. + +## Research Question And Scope + +State the exact question, affected modules, and what is out of scope. + +## Source Inventory + +### Official Google Docs + +| Source | Date Checked | Relevance | +| --- | --- | --- | +| | | | + +### Library And Framework Docs + +| Source | Date Checked | Relevance | +| --- | --- | --- | +| | | | + +### Release Notes And Changelogs + +| Source | Date Checked | Relevance | +| --- | --- | --- | +| | | | + +### GitHub Source, Issues, And Pull Requests + +| Source | Date Checked | Relevance | +| --- | --- | --- | +| | | | + +### Project-Local Evidence + +| Source | Date Checked | Relevance | +| --- | --- | --- | +| | | | + +## Verified Facts + +List facts directly supported by sources. Cite each item. + +## Source-Backed Findings + +Explain what the facts mean for the research question. + +## Inferences + +Label any reasoning that is not directly stated by a source. + +## Project Impact + +Describe impact by module or workflow: `:app`, `:common`, `:xposed`, `:verifier`, validation, release/R8, docs, or packaging. + +## Compatibility Risks And Edge Cases + +List practical failure modes, unsupported cases, Android-version differences, target-app differences, and validation hazards. + +## Unknowns And Gaps + +List unanswered questions and what evidence would close them. + +## Recommendations + +Give concrete, ranked recommendations. Separate must-do fixes from optional improvements. + +## Suggested Next Tasks + +Use actionable tasks with a verification step where possible. + +## Memory Bank And Docs Recommendations + +State whether Memory Bank or docs should be updated later. Do not update them during this skill workflow. + +## Write Boundary Confirmation + +State that this report was the only file written and no other files were edited, created, moved, committed, or pushed. +``` diff --git a/DeviceMasker-main/.agents/skills/device-masker-review/SKILL.md b/DeviceMasker-main/.agents/skills/device-masker-review/SKILL.md new file mode 100644 index 000000000..261f4d454 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/device-masker-review/SKILL.md @@ -0,0 +1,139 @@ +--- +name: device-masker-review +description: Evidence-backed review, audit, and code-review workflow for the Device Masker Android LSPosed/libxposed project that may write exactly one internal audit report file and must not modify any other project files. Use when reviewing or auditing current code, architecture, architecture-improvement opportunities, UI/UX, accessibility, folder structure, AGENTS.md/module-guide correctness, docs, reports, tests, validation evidence, release readiness, hook safety, R8 safety, Android compatibility, stale Memory Bank content, or project-rule compliance. +--- + +# Device Masker Review + +Use this skill to inspect the actual repository state and write a findings-first review/audit report. This is local-first: source files, tests, docs, reports, logs, and project rules are the primary evidence. External docs are supporting evidence only when current platform/API behavior matters. + +## Write Boundary + +Write exactly one Markdown report file only. Use this path shape by default: + +```text +docs/internal/reports/active/audits/YYYY-MM-DD/YYYY-MM-DD-topic-kebab-case.md +``` + +Use `closed/` only when the user explicitly asks for a final/closed/historical report or the review/audit is clearly superseded/completed. + +Do not create, edit, move, rename, or delete any other project file or folder. Do not write logs, raw evidence files, Memory Bank updates, docs, source changes, config changes, build artifacts, commits, branches, tags, or pull requests. If the parent date folder is missing, create only the minimum parent folder required for the single report path. + +If the review finds required code, docs, AGENTS.md, Memory Bank, report, or architecture changes, record the exact recommended edits inside the report instead of applying them. + +Treat all `AGENTS.md` files and module guides as review targets, not as automatically correct truth. This project is under active development and has frequent breaking changes, validation, package, hook, and documentation changes. For any review that touches rules, architecture, workflow, or module boundaries, compare every relevant `AGENTS.md` rule against current code, Memory Bank, public docs, reports, and validation evidence. Flag stale, contradictory, missing, or over-specific rules and propose exact updates in the report, and be 100% confident and sure about it. + +## Review Workflow + +1. Define the review scope: changed files, module, report/doc set, runtime evidence, architecture area, or release-readiness question. +2. Read required project context before judging code. +3. Inspect real files without modifying them. Prefer `rg`, `rg --files`, `git diff`, `git status`, `git show`, and targeted file reads. +4. Review tests and verification evidence before implementation details when a change claims behavior. +5. Review implementation against Device Masker rules and module boundaries. +6. Review architecture improvement opportunities: data-model shape, module ownership, dependency direction, hook lifecycle, validation flow, report lifecycle, and agent/project rules. Propose improvements only when they remove proven complexity, stale assumptions, or repeated failure modes. +7. Check all relevant `AGENTS.md` files, docs, reports, Memory Bank, and public validation claims for stale, contradictory, or overstated information. +8. Use external docs only when needed: + - Google Developer Knowledge MCP for Android, Google APIs, Play, Firebase, Material, web.dev, Google Cloud, and official Google developer docs. + - Context7 MCP for non-Google library/framework/API docs. + - Web search/GitHub for current releases, changelogs, breaking changes, upstream source, issues, and missing MCP coverage. +9. Separate verified facts, inferences, risks, and unknowns. Do not mix them. +10. Write one comprehensive findings-first review/audit report file at the approved report path. +11. If project docs or Memory Bank should change, list exact recommended edits instead of applying them. + +For GitHub PR review comments, use the installed `gh-address-comments` workflow or GitHub plugin skills when the task is specifically to inspect/address PR comments. Do not treat PR comments alone as a full project review unless the user asks for one. + +## Review Axes + +Evaluate the relevant axes for every review: + +- Correctness: behavior matches requirements, edge cases, error paths, Android-version differences, target-app behavior, and tests. +- Xposed safety: stable hooker callback shape, deoptimization, pass-through fallback, no target-process random generation, no Timber/Compose/private JSON/hardcoded keys in `:xposed`, no broad risky hooks without opt-in. +- Architecture and data model: RemotePreferences-first config, `JsonConfig.appConfigs` canonical scope, `SharedPrefsKeys` key ownership, coherent identity data, narrow interfaces, module boundaries. +- Architecture improvement: identify simpler data shapes, cleaner module ownership, safer hook lifecycle boundaries, better validation/report flows, and rule/doc updates that match the current development reality. +- Verification integrity: tests, lint, Detekt, R8 guard, emulator/device evidence, LSPosed/logcat proof, verifier JSON, exact expected-vs-actual values. +- Docs, rules, and reports: report category/date rules, public validation separation, stale claims, Memory Bank accuracy, and whether root/module `AGENTS.md` files still match current code and architecture. +- Security and privacy: secrets, raw identifiers in logs, unsafe input/config handling, command construction, root/logcat capture boundaries. +- Performance and concurrency: blocking calls, hot hook callback allocations/reflection, lock contention, unbounded work, Compose recomposition hotspots. +- UI/UX/accessibility: navigation clarity, workflow efficiency, state feedback, error recovery, loading/empty states, touch targets, text scaling, contrast, TalkBack semantics, reduced motion, edge-to-edge/insets, screen-density behavior, and consistency with Material 3 Expressive/project UI rules when the review scope includes app UI or public-facing workflow. + +## UI/UX Review Audit + +Include this section when the user asks for UI/UX review, visual audit, accessibility review, screen-flow review, or when the reviewed change touches Compose UI, navigation, public validation UX, diagnostics UX, settings/export UX, group/app assignment, or target-app configuration workflows. + +Check: + +- Workflow fit: the primary user action is visible, reversible, and does not require hidden state knowledge. +- Information hierarchy: screen titles, section headings, status labels, and density fit operational Android tooling instead of marketing-style layout. +- State clarity: loading, empty, disabled, error, partial-success, root unavailable, module unavailable, target unscoped, and validation unknown states are explicit. +- Navigation and back behavior: top-level stacks, detail screens, deep links, dialogs, bottom sheets, and back navigation preserve user context. +- Accessibility: touch targets, content descriptions, TalkBack order, focus behavior, text scaling, high contrast, dynamic color, reduced motion, and keyboard/IME behavior. +- Layout resilience: compact/medium/expanded widths, landscape, edge-to-edge insets, navigation bar/status bar overlap, long labels, translated strings, and dense data tables do not overlap or truncate critical content. +- Domain trust: UI copy must not overclaim hook success, Android 16 readiness, real-device proof, or anti-detection coverage from weak evidence. +- Visual consistency: Material 3 Expressive tokens, spacing, shape, typography, icon usage, and component choices match existing Device Masker patterns. +- Verification: prefer screenshot evidence, Mobile MCP visual checks, Compose previews, accessibility scanner/TalkBack notes, and targeted UI tests when available. + +## Report Shape + +Use this section order unless the user asks for a smaller answer: + +- Findings, severity ordered: `Critical`, `High`, `Medium`, `Low`, `Info` +- Executive summary +- Scope +- Source inventory +- Project rule violations +- AGENTS.md and rule drift audit +- Root cause analysis +- Recommended fixes +- Architecture improvement opportunities +- UI/UX review audit +- Best solution direction +- Optional improvements +- Proposed APIs, interfaces, dependencies, or tools +- Rejected or risky approaches +- Verification plan +- Residual risks and unknowns +- Suggested next tasks +- Report file path +- Write boundary confirmation + +## Findings Standard + +Each finding should include: + +- Severity: `Critical`, `High`, `Medium`, `Low`, or `Info`. +- Evidence: file/line links, report paths, log paths, command output already present on disk, or source links. +- Problem: what is wrong and why it matters. +- Root cause: the data shape, API misuse, lifecycle mismatch, validation gap, or stale documentation pattern behind it. +- Fix direction: practical remediation that matches existing project patterns. +- Verification: the command, runtime check, or document comparison that would prove the fix. + +Findings should lead the report. Summaries and broad context come after findings, not before them. + +## Hard Rules + +- Do not claim hook success from app launch, app-side service connection, or config presence alone. +- Do not claim physical-device stability from emulator-only evidence. +- Do not accept hand-wavy performance, safety, or correctness claims. +- Do not recommend broad rewrites or new dependencies unless they directly reduce a proven risk. +- Do not create Detekt baseline debt as a review "fix". +- Do not move reports or docs during this workflow. +- Do not treat existing `AGENTS.md` rules as immutable when current code, Memory Bank, and evidence prove they are stale. Report the mismatch and propose precise rule changes. + +## Combined Research And Review Requests + +If the user asks for research and review/audit together: + +- Still write exactly one report file. +- Classify by the primary output: use `audits/` when repository findings drive the answer; use the research workflow when external source facts drive the answer. +- Include clear `Research Inputs` and `Review Findings` sections when both outputs are substantial. + +Use `.agents/skills/device-masker-research/SKILL.md` when external research is substantial or is the primary output. + +## Final Response + +End with: + +- Highest-severity findings or "no findings". +- Verification checks performed and anything not run. +- Report file path. +- Confirmation that only the report file was written and no other files were edited, created, moved, committed, or pushed by the skill workflow. diff --git a/DeviceMasker-main/.agents/skills/device-masker-review/agents/openai.yaml b/DeviceMasker-main/.agents/skills/device-masker-review/agents/openai.yaml new file mode 100644 index 000000000..adae178be --- /dev/null +++ b/DeviceMasker-main/.agents/skills/device-masker-review/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Device Masker Review" + short_description: "Device Masker review/audit report" + default_prompt: "Use $device-masker-review to review or audit Device Masker for correctness, architecture, UI/UX, accessibility, hook safety, validation gaps, and stale documentation, then write one internal report file only." diff --git a/DeviceMasker-main/.agents/skills/device-masker-review/references/report-template.md b/DeviceMasker-main/.agents/skills/device-masker-review/references/report-template.md new file mode 100644 index 000000000..7fa17beb1 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/device-masker-review/references/report-template.md @@ -0,0 +1,134 @@ +# Review/Audit Report Template + +Use this template for the single Device Masker review/audit report file written by the skill. + +```markdown +# YYYY-MM-DD Topic Review + +Date: YYYY-MM-DD +Mode: Review/audit report +Report path: docs/internal/reports/active/audits/YYYY-MM-DD/YYYY-MM-DD-topic-kebab-case.md + +## Findings + +### Critical + +No findings, or list findings in this shape: + +#### [C-1] Short Finding Title + +- Evidence: file/report/log/source links with line numbers where possible. +- Problem: what is wrong and why it matters. +- Root cause: the deeper design, data, lifecycle, API, or workflow cause. +- Fix direction: practical remediation aligned with project patterns. +- Verification: command, runtime check, or document comparison that would prove the fix. + +### High + +### Medium + +### Low + +### Info + +## Executive Summary + +Summarize the review result after the findings. Do not hide important findings here. + +## Scope + +State what was reviewed, affected modules, and what was out of scope. + +## Source Inventory + +### Project Files + +| Source | Relevance | +| --- | --- | +| | | + +### Reports, Docs, And Memory Bank + +| Source | Relevance | +| --- | --- | +| | | + +### Existing Logs And Runtime Evidence + +| Source | Relevance | +| --- | --- | +| | | + +### External Docs Or GitHub Sources + +| Source | Date Checked | Relevance | +| --- | --- | --- | +| | | | + +## Project Rule Violations + +List concrete violations of `AGENTS.md`, `docs/AGENTS_PROJECT_RULES.md`, module guides, or Memory Bank rules. + +## AGENTS.md And Rule Drift Audit + +Compare every relevant `AGENTS.md` and module guide against current code, Memory Bank, reports, architecture, and validation evidence. List stale, contradictory, missing, or over-specific rules. Explain why each rule is wrong or incomplete now, especially when frequent development or breaking changes made old guidance unsafe. + +## Root Cause Analysis + +Summarize repeated causes across findings, such as wrong data shape, lifecycle mismatch, unsafe hook strategy, stale docs, or weak validation. + +## Recommended Fixes + +Rank required fixes. Include file/module ownership where useful. + +## Architecture Improvement Opportunities + +List architecture improvements only when they remove proven complexity, stale assumptions, unsafe boundaries, duplicated workflow, or repeated failure modes. Include the current pain, the better shape, and the migration/verification path. + +## UI/UX Review Audit + +Include this section for UI, workflow, navigation, accessibility, diagnostics, settings/export, public validation, group/app assignment, or target configuration reviews. + +Cover: +- Workflow fit and primary actions. +- Information hierarchy and density. +- Loading, empty, disabled, error, partial-success, unavailable, and unknown states. +- Navigation, back behavior, dialogs, sheets, deep links, and state restoration. +- Accessibility: touch targets, content descriptions, TalkBack order, focus, text scaling, contrast, reduced motion, and IME behavior. +- Layout resilience across compact/medium/expanded widths, landscape, edge-to-edge insets, long labels, and dense tables. +- Domain-trust copy: no overclaiming hook success, Android readiness, real-device proof, or anti-detection coverage. +- Visual consistency with Material 3 Expressive and existing Device Masker patterns. +- Verification evidence such as screenshots, Mobile MCP checks, previews, accessibility notes, or UI tests. + +## Best Solution Direction + +Describe the safest durable direction if multiple fixes are possible. + +## Optional Improvements + +List improvements that are useful but not required to close the review. + +## Proposed APIs, Interfaces, Dependencies, Or Tools + +Only include proposals that directly reduce proven complexity or risk. State why existing project patterns are insufficient. + +## Rejected Or Risky Approaches + +List approaches that should not be taken and why. + +## Verification Plan + +Use concrete checks. For code changes, include Gradle/test/static/runtime commands where relevant. + +## Residual Risks And Unknowns + +List what remains unproven and what evidence would close it. + +## Suggested Next Tasks + +Use actionable tasks with verification steps. + +## Write Boundary Confirmation + +State that this report was the only file written and no other files were edited, created, moved, committed, or pushed. +``` diff --git a/DeviceMasker-main/.agents/skills/edge-to-edge/SKILL.md b/DeviceMasker-main/.agents/skills/edge-to-edge/SKILL.md new file mode 100644 index 000000000..f618ab25d --- /dev/null +++ b/DeviceMasker-main/.agents/skills/edge-to-edge/SKILL.md @@ -0,0 +1,426 @@ +--- +name: edge-to-edge +description: Use this skill to migrate your Jetpack Compose app to add adaptive edge-to-edge + support and troubleshoot common issues. Use this skill to fix UI components (like + buttons or lists) that are obscured by or overlapping with the navigation bar or + status bar, fix IME insets, and fix system bar legibility. +license: Complete terms in LICENSE.txt +metadata: + author: Google LLC + last-updated: '2026-04-01' + keywords: + - android + - compose + - system bars + - edge-to-edge + - status bar + - navigation bar +--- + +## Prerequisites + +- Project **MUST** use Android Jetpack Compose. +- Project **MUST** target SDK 35 or later. If the SDK is lower than 35, increase the SDK to 35. + +## Step 1: plan + +1. Locate and analyze all Activity classes to detect which have existing edge-to-edge support. For every Activity without edge-to-edge, plan to make each Activity edge-to-edge. +2. In each Activity, Locate and analyze all lists and FAB components to detect which have existing edge-to-edge support. For every component without edge-to-edge support, plan to make each of these components edge-to-edge. +3. In each Activity, scan for `TextField`, `OutlinedTextField`, or `BasicTextField`. If found, then you **MUST** verify the IME doesn't hide the input field by following the IME section of this skill. + +## Step 2: add edge-to-edge support + +1. Add `enableEdgeToEdge` before `setContent` in `onCreate` in each Activity that does not already call `enableEdgeToEdge`. +2. Add `android:windowSoftInputMode="adjustResize"` in the AndroidManifest.xml for all Activities that use a soft keyboard. + +## Step 3: apply insets + +- The app **MUST** apply system insets, or align content to rulers, so critical + UI remains tappable. Choose only one method to avoid double padding: + + 1. **PREFERRED:** When available, use `Scaffold`s and pass `PaddingValues` to the content lambda. + + + ```kotlin + Scaffold { innerPadding -> + // innerPadding accounts for system bars and any Scaffold components + LazyColumn( + modifier = Modifier + .fillMaxSize() + .consumeWindowInsets(innerPadding), + contentPadding = innerPadding + ) { /* Content */ } + } + ``` + +
+ + 1. **PREFERRED:** When available, use the automatic inset handling or padding modifiers in material components. + + - Material 3 Components manages safe areas for its own components, including: + - `TopAppBar` + - `SmallTopAppBar` + - `CenterAlignedTopAppBar` + - `MediumTopAppBar` + - `LargeTopAppBar` + - `BottomAppBar` + - `ModalDrawerSheet` + - `DismissibleDrawerSheet` + - `PermanentDrawerSheet` + - `ModalBottomSheet` + - `NavigationBar` + - `NavigationRail` + - For Material 2 Components, use the `windowInsets`parameter to apply insets manually for `BottomAppBar`, `TopAppBar` and `BottomNavigation`. **DO NOT** apply padding to the parent container; instead, pass insets directly to the App Bar component. Applying padding to the parent container prevents the App Bar background from drawing into the system bar area. For example, for `TopAppBar`, choose only one of the following options: + 1. **PREFERRED:** `TopAppBar(windowInsets = AppBarDefaults.topAppBarWindowInsets)` + 2. `TopAppBar(windowInsets = WindowInsets.systemBars.exclude(WindowInsets.navigationBars))` + 3. `TopAppBar(windowInsets = WindowInsets.systemBars.add(WindowInsets.captionBar))` + 2. For components outside a Scaffold, use padding modifiers, such as `Modifier.safeDrawingPadding()` or `Modifier.windowInsetsPadding(WindowInsets.safeDrawing)`. + + + ```kotlin + Box( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + ) { + Button( + onClick = {}, + modifier = Modifier.align(Alignment.BottomCenter) + ) { + Text("Login") + } + } + ``` + +
+ + 3. For deeply nested components with excessive padding, use `WindowInsetsRulers` (e.g. `Modifier.fitInside(WindowInsetsRulers.SafeDrawing.current)`). See the *IME* section for a code sample. + + 4. When you need an element (e.g. a custom header or decorative scrim) to + equal the dimensions of a system bar, use inset size modifiers (e.g. + `Modifier.windowInsetsTopHeight(WindowInsets.systemBars)`). + See the *Lists* section for a code sample. + +## Adaptive Scaffolds + +- `NavigationSuiteScaffold` manages safe areas for its own components, like the `NavigationRail` or `NavigationBar`. However, the adaptive scaffolds (e.g. `NavigationSuiteScaffold`, `ListDetailPaneScaffold`) don't propagate PaddingValues to their inner contents. You **MUST** apply insets to **individual** screens or components (e.g., list `contentPadding` or FAB padding) as described in *Step 3* . **DO NOT** apply `safeDrawingPadding` or similar modifiers to the `NavigationSuiteScaffold` parent. This clips and prevents an edge-to-edge screen. + +## IME + +- For each Activity with a soft keyboard, check that `android:windowSoftInputMode="adjustResize"` is set in the AndroidManifest.xml. DO NOT use `SOFT_INPUT_ADJUST_RESIZE` because it is deprecated. Then, maintain focus on the input field. Choose one: + - 1. **PREFERRED:** Add `Modifier.fitInside(WindowInsetsRulers.Ime.current)` to the content container. This is preferred over `imePadding()` because it reduces jank and extra padding caused by forgetting to consume insets upstream in the hierarchy. + - 2. Add `imePadding` to the content container. The padding modifier **MUST** be placed before `Modifier.verticalScroll()`. Do NOT use `Modifier.imePadding()` if the parent already accounts for the IME with `contentWindowInsets` (e.g. `contentWindowInsets = + WindowInsets.safeDrawing`). Doing so will cause double padding. + +### IMEs with Scaffolds code patterns + +#### RIGHT + +RIGHT because `contentWindowInsets` contains IME insets, which are passed to the +content lambda as `innerPadding`. + + +```kotlin +// RIGHT +Scaffold(contentWindowInsets = WindowInsets.safeDrawing) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .consumeWindowInsets(innerPadding) + .verticalScroll(rememberScrollState()) + ) { /* Content */ } +} +``` + +
+ +*** ** * ** *** + +RIGHT because `fitInside` fits the content to the IME insets regardless of +`contentWindowInsets`. + + +```kotlin +// RIGHT +Scaffold() { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .consumeWindowInsets(innerPadding) + .fitInside(WindowInsetsRulers.Ime.current) + .verticalScroll(rememberScrollState()) + ) { /* Content */ } +} +``` + +
+ +*** ** * ** *** + +RIGHT because the default `contentWindowInsets` does not contain IME insets, and +`imePadding()` applies IME insets: + + +```kotlin +// RIGHT +Scaffold() { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .consumeWindowInsets(innerPadding) + .imePadding() + .verticalScroll(rememberScrollState()) + ) { /* Content */ } +} +``` + +
+ +#### WRONG + +WRONG because there will be excess padding when the IME opens. IME insets are +applied twice, once with innerPadding, which contains IME insets from the passed +`contentWindowInsets` values, and once with `imePadding`: + + +```kotlin +// WRONG +Scaffold( contentWindowInsets = WindowInsets.safeDrawing ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .imePadding() + .verticalScroll(rememberScrollState()) + ) { /* Content */ } +} +``` + +
+ +*** ** * ** *** + +WRONG because the IME will cover up the content. Scaffold's default +`contentWindowInsets` does NOT contain IME insets. + + +```kotlin +// WRONG +Scaffold() { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + ) { /* Content */ } +} +``` + +
+ +### IMEs without Scaffolds code patterns + +#### RIGHT + +The following code samples WILL NOT cause excessive padding. + + +```kotlin +// RIGHT +Box( + // Insets consumed + modifier = Modifier.safeDrawingPadding() // or imePadding(), safeContentPadding(), safeGesturesPadding() +) { + Column( + modifier = Modifier.imePadding() + ) { /* Content */ } +} +``` + +
+ +*** ** * ** *** + + +```kotlin +// RIGHT +Box( + // Insets consumed + modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing) // or WindowInsets.ime, WindowInsets.safeContent, WindowInsets.safeGestures +) { + Column( + modifier = Modifier.imePadding() + ) { /* Content */ } +} +``` + +
+ +*** ** * ** *** + + +```kotlin +// RIGHT +Box( + // Insets not consumed, but irrelevant due to fitInside + modifier = Modifier.padding(WindowInsets.safeDrawing.asPaddingValues()) // or WindowInsets.ime.asPaddingValues(), WindowInsets.safeContent.asPaddingValues(), WindowInsets.safeGestures.asPaddingValues() +) { + Column( + modifier = Modifier + .fillMaxSize() + .fitInside(WindowInsetsRulers.Ime.current) + ) { /* Content */ } +} +``` + +
+ +#### WRONG + +The following code sample WILL cause excessive padding because IME insets are +applied twice: + + +```kotlin +// WRONG +Box( + // Insets not consumed + modifier = Modifier.padding(WindowInsets.safeDrawing.asPaddingValues()) // or WindowInsets.ime.asPaddingValues(), WindowInsets.safeContent.asPaddingValues(), WindowInsets.safeGestures.asPaddingValues() +) { + Column( + modifier = Modifier.imePadding() + ) { /* Content */ } +} +``` + +
+ +## Navigation Bar Contrast \& System Bar Icons + +- If the Activity uses `enableEdgeToEdge` from `WindowCompat`, you **MUST** set + `isAppearanceLightNavigationBars` and `isAppearanceLightStatusBars` to the + inverse of the device theme for apps that support light and dark theme so the + system bar icons are legible. It's recommended to do this in your theme file. + DO NOT do this if the Activities use `enableEdgeToEdge` from `ComponentActivity` + because it handles the icon colors automatically. + + + ```kotlin + // Only use if calling `enableEdgeToEdge` from `WindowCompat`. + // Apply to your theme file. + @Composable + fun MyTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit + ) { + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as? Activity)?.window ?: return@SideEffect + val controller = WindowCompat.getInsetsController(window, view) + + // Dark icons for Light Mode (!darkTheme), Light icons for Dark Mode + controller.isAppearanceLightStatusBars = !darkTheme + controller.isAppearanceLightNavigationBars = !darkTheme + } + } + + MaterialTheme(content = content) + } + ``` + +
+ +- If any screen uses a `Scaffold` or a `NavigationSuiteScaffold` with a bottom + bar (e.g., `BottomAppBar`, `NavigationBar`), set + `window.isNavigationBarContrastEnforced = false` in the corresponding Activity + for SDK 29+. This prevents the system from adding a translucent background to + the navigation bar, verifying your bottom bar colors extend to the bottom of the + screen. + +## Lists + +- Apply inset padding (like `Scaffold`'s `innerPadding`) to the `contentPadding` parameter of scrollable components (e.g. `LazyColumn`, `LazyRow`). DO NOT apply it as a `Modifier.padding()` to the list's parent container, as this clips the content and prevents it from scrolling behind the system bars. +- Create a translucent composable covering the system bar so that the icons are still legible. + + +```kotlin +class SystemBarProtectionSnippets : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // enableEdgeToEdge sets window.isNavigationBarContrastEnforced = true + // which is used to add a translucent scrim to three-button navigation + enableEdgeToEdge() + + setContent { + MyTheme { + // Main content + MyContent() + + // After drawing main content, draw status bar protection + StatusBarProtection() + } + } + } +} + +@Composable +private fun StatusBarProtection( + color: Color = MaterialTheme.colorScheme.surfaceContainer, +) { + Spacer( + modifier = Modifier + .fillMaxWidth() + .height( + with(LocalDensity.current) { + (WindowInsets.statusBars.getTop(this) * 1.2f).toDp() + } + ) + .background( + brush = Brush.verticalGradient( + colors = listOf( + color.copy(alpha = 1f), + color.copy(alpha = 0.8f), + Color.Transparent + ) + ) + ) + ) +} +``` + +
+ +## Dialogs + +If both the following conditions are true, then the Dialog is full screen and +must be made edge-to-edge: +1. The `DialogProperties` contains `usePlatformDefaultWidth = false`. +2. The Dialog calls `Modifier.fillMaxSize()`. + +To make a full screen Dialog edge-to-edge, set `decorFitsSystemWindows = false` +in the `DialogProperties`. + + +```kotlin +Dialog( + onDismissRequest = { /* Handle dismiss */ }, + properties = DialogProperties( + // 1. Allows the dialog to span the full width of the screen + usePlatformDefaultWidth = false, + // 2. Allows the dialog to draw behind status and navigation bars + decorFitsSystemWindows = false + ) +) { /* Content */ } +``` + +
+ +## Checklist + +- \[ \] Does every `Activity` call `enableEdgeToEdge()`? +- \[ \] Is `adjustResize` set in the `AndroidManifest.xml`? +- \[ \] Does every `TextField`, `OutlinedTextField`, or `BasicTextField` have a parent with `imePadding()`, `fitInside`, `Modifier.safeDrawingPadding()`, `Modifier.safeContentPadding()`, `Modifier.safeGesturesPadding()`, or `contentWindowInsets` set to `WindowInsets.safeDrawing` or `WindowInsets.ime`? +- \[\] Does the first and last list item draw away from the system bars by passing insets to `contentPadding`? +- \[\] Do FABs draw above the navigation bars by either being inside a Scaffold or by applying `Modifier.safeDrawingPadding()`? +- \[\] Does the project build? Run `./gradlew build` to be sure. diff --git a/DeviceMasker-main/.agents/skills/jetpack-compose/adaptive/SKILL.md b/DeviceMasker-main/.agents/skills/jetpack-compose/adaptive/SKILL.md new file mode 100644 index 000000000..93cf80f4b --- /dev/null +++ b/DeviceMasker-main/.agents/skills/jetpack-compose/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-15' + 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/DeviceMasker-main/.agents/skills/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/container-behavior.md b/DeviceMasker-main/.agents/skills/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/container-behavior.md new file mode 100644 index 000000000..ad4862f0e --- /dev/null +++ b/DeviceMasker-main/.agents/skills/jetpack-compose/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`. + +|---|---| +| | ![Illustration of a horizontal main axis.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/main-axis.png) | +| `Start` | ![Items aligned to the start of the main axis.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/mainaxis-start.png) | +| `Center` | ![Items aligned to the center of the main axis.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/mainaxis-center.png) | +| `End` | ![Items aligned to the end of the main axis.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/mainaxis-end.png) | +| `SpaceBetween` | ![Items distributed along the main axis with space between them.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/mainaxis-spacebetween.png) | +| `SpaceAround` | ![Items distributed along the main axis with space around them.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/mainaxis-spacearound.png) | +| `SpaceEvenly` | ![Items distributed along the main axis with space evenly around them.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/mainaxis-spaceevenly.png) | + +### 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`: + +|---|---|---|---|---|---| +| ![Illustration of a vertical cross axis.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/crossaxis.png) | ![Items aligned to the start of the cross axis.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/crossaxis-start.png) | ![Items aligned to the end of the cross axis.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/crossaxis-end.png) | ![Items aligned to the center of the cross axis.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/crossaxis-center.png) | ![Items stretched to fill the cross axis.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/crossaxis-stretch.png) | ![Items aligned to their baseline along the cross axis.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/crossaxis-baseline.png) | +| | `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`: + +|---|---|---|---|---|---|---| +| ![Illustration of a vertical cross axis.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/crossaxis.png) | ![Multiple lines of items aligned to the start of the cross axis.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/aligncontent-start.png) | ![Multiple lines of items aligned to the end of the cross axis.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/aligncontent-end.png) | ![Multiple lines of items aligned to the center of the cross axis.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/aligncontent-center.png) | ![Multiple lines of items stretched to fill the cross axis.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/aligncontent-stretch.png) | ![Multiple lines of items distributed along the cross axis with space between them.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/aligncontent-spacebetween.png) | ![Multiple lines of items distributed along the cross axis with space around them.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/aligncontent-spacearound.png) | +| | `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. | ![Items in a single line overflowing the container because wrapping is disabled.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/wrapitems-1.png) | +| `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**. | ![Items wrapping onto a new line below because wrapping is enabled.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/wrapitems-2.png) | +| `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**. | ![Items wrapping onto a new line above because reverse wrapping is enabled.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/wrapitems-3.png) | + +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. +![First item placed in the FlexBox container.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/algorithm-1.png) **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. +![First item placed in the FlexBox container.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/algorithm-2.png) **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. +![Third item placed on a new line because it doesn't fit on the first line.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/algorithm-3.png) **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. + +|---|---|---| +| ![Row gap adds vertical space between items.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/gap-1.png) | ![Column gap adds horizontal space between items.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/gap-2.png) | ![Gap adds both horizontal and vertical space between items.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/gap-3.png) | +| `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/DeviceMasker-main/.agents/skills/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/get-started.md b/DeviceMasker-main/.agents/skills/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/get-started.md new file mode 100644 index 000000000..916465217 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/jetpack-compose/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-alpha02" + + [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) +} +``` + +
+ +![Hello World text composables stacked on top of each other in a basic FlexBox implementation.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/basic-flexbox.png) + +**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) }) +} +``` + +
+ +![Two rows of colored items, with three unequally sized items distributed across the top row and two unequally sized items across the bottom row.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/basic-flexbox-2.png) + +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/DeviceMasker-main/.agents/skills/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/index.md b/DeviceMasker-main/.agents/skills/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/index.md new file mode 100644 index 000000000..ddda0debc --- /dev/null +++ b/DeviceMasker-main/.agents/skills/jetpack-compose/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. + + +![FlexBox with horizontal main axis and vertical cross axis.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/intro-row-2.png) **Figure 1.** Axes and sizes when the `FlexBox` direction is `Row`. ![FlexBox with vertical main axis and horizontal cross axis.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/intro-column.png) **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/DeviceMasker-main/.agents/skills/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/item-behavior.md b/DeviceMasker-main/.agents/skills/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/flexbox/item-behavior.md new file mode 100644 index 000000000..e4a5c0f57 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/jetpack-compose/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) } ) } ``` | ![Items sized based on their intrinsic size using basis Auto.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/initialsize-1.png) | +| Fixed `dp` | A fixed size in Dp. | ```kotlin FlexBox { RedRoundedBox( Modifier.flex { basis(200.dp) } ) BlueRoundedBox( Modifier.flex { basis(100.dp) } ) } ``` | ![Items sized to a fixed dp value using basis.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/initialsize-2.png) | +| Percentage | A percentage of the container size. | ```kotlin FlexBox { RedRoundedBox( Modifier.flex { basis(0.7f) } ) BlueRoundedBox( Modifier.flex { basis(0.3f) } ) } ``` | ![Items sized as a percentage of container size using basis.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/initialsize-3.png) | + +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. ![Three items with 100dp basis each, in a 600dp container, before growth.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/growitems-1.png) Child 1 grows by `300dp` to fill the extra space. ![First item grows to fill 300dp of extra space.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/growitems-2.png) | + +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. ![Three items with 100dp basis each, in a 600dp container, before growth, with different grow values.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/growitems-3.png) 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` ![Items grow to fill 300dp of extra space based on relative grow values.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/growitems-4.png) | + +### 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` | ![Two items in a 700dp container.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/containersize-1.png) | +| `500dp` | ![First item shrinks as container size reduces to 500dp.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/containersize-2.png) | +| `450dp` | ![First item shrinks further as container size reduces to 450dp.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/containersize-3.png) | + +## 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) }) +} +``` + +
+ +![Five children of varying sizes overriding the alignItems property.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/item-alignment.png) + +## 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) + } + ) +} +``` + +
+ +![Two rounded boxes, with the first containing the text Hello and the second containing the text World.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/flexbox/itemorder.png) \ No newline at end of file diff --git a/DeviceMasker-main/.agents/skills/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/container-properties.md b/DeviceMasker-main/.agents/skills/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/container-properties.md new file mode 100644 index 000000000..63d160c90 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/jetpack-compose/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() +} +``` + +
+ +![Six cards are placed in a grid that has three rows and two columns.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/grid/placement.png) **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() +} +``` + +
+ +![The flow function changes the direction to place items.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/grid/grid-flow.png) **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)") +``` + +
+ +![Row heights defined using the four primary track sizing options.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/grid/track-sizes.png) **Figure 3** . Row heights defined using the four primary track sizing options in `Grid`. + +### 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." ) +} +``` + +
+ +![Intrinsic sizes specified in the columns.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/grid/intrinsic-size.png) **Figure 4**. Intrinsic sizes specified in the columns. + +## 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() +} +``` + +
+ +![Gaps between rows and columns.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/grid/gaps.png) **Figure 5**. Gaps between rows and columns. + +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/DeviceMasker-main/.agents/skills/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/get-started.md b/DeviceMasker-main/.agents/skills/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/get-started.md new file mode 100644 index 000000000..1c51d222f --- /dev/null +++ b/DeviceMasker-main/.agents/skills/jetpack-compose/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-alpha02" + + [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) +} +``` + +
+ +![A basic grid consists of rows and columns with fixed size.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/grid/six-cards-in-grid.png) **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/DeviceMasker-main/.agents/skills/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/index.md b/DeviceMasker-main/.agents/skills/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/index.md new file mode 100644 index 000000000..1f74aee7e --- /dev/null +++ b/DeviceMasker-main/.agents/skills/jetpack-compose/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. +![A flexible and adaptive two-dimensional layout with Grid](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/grid/example.png) **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: +![The grid consists of four horizontal lines and three vertical lines.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/grid/grid-line.png) **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. +![A grid track for the first row.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/grid/grid-track.png) **Figure 3**. A grid track for the first row. + +### Grid cell + +A grid cell is the intersection of a row and column track. +![A grid cell that is an intersection of the second row and the second column.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/grid/grid-cell.png) **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. +![A grid area that consists of four grid cells.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/grid/grid-area.png) **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. +![A grid gap between the first column and the second column.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/grid/grid-gap.png) **Figure 6**. A grid gap between the first column and the second column. \ No newline at end of file diff --git a/DeviceMasker-main/.agents/skills/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/item-properties.md b/DeviceMasker-main/.agents/skills/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/grid/item-properties.md new file mode 100644 index 000000000..f5ecc87ca --- /dev/null +++ b/DeviceMasker-main/.agents/skills/jetpack-compose/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)) +} +``` + +
+ +![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.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/grid/position.png) **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)) +} +``` + +
+ +![Card #4 spans three columns](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/grid/spanning.png) **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)) +} +``` + +
+ +![The Text with #1 is placed in the center of the grid area +consisting of two rows and two columns.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/grid/alignment.png) **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() +} +``` + +
+ +![Card #3 is placed next to card #1, as it is an auto-placement.](https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/grid/autoplacement-mixed-with-placement.png) **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/DeviceMasker-main/.agents/skills/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/mediaquery/index.md b/DeviceMasker-main/.agents/skills/jetpack-compose/adaptive/references/android/develop/ui/compose/layouts/adaptive/mediaquery/index.md new file mode 100644 index 000000000..4cd256487 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/jetpack-compose/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/DeviceMasker-main/.agents/skills/jetpack-compose/adaptive/references/android/develop/ui/compose/tooling/debug.md b/DeviceMasker-main/.agents/skills/jetpack-compose/adaptive/references/android/develop/ui/compose/tooling/debug.md new file mode 100644 index 000000000..77bc9da9d --- /dev/null +++ b/DeviceMasker-main/.agents/skills/jetpack-compose/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. + +![](https://developer.android.com/static/develop/ui/compose/images/li-recomposition-counts.png) **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 ![Layout Inspector View Options +> icon](https://developer.android.com/static/studio/images/buttons/live-layout-inspector-view-options-icon.png) near the top of the **Component Tree** pane, as shown in the following image. + +![Enable the composition and skip counter in Layout +Inspector](https://developer.android.com/static/develop/ui/compose/images/li-show-recomposition-counts.png) + +**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. +![Semantic information displayed using the Layout Inspector.](https://developer.android.com/static/develop/ui/compose/images/layout_inspector_semantics_new.png) **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: +![](https://developer.android.com/static/studio/images/design/compose-ui-check-entry.png) **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. +![](https://developer.android.com/static/studio/images/design/compose-ui-check.png) **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. +![](https://developer.android.com/static/studio/preview/features/images/ui-check-mode-single-fix.png) **Figure 7.** The agent fixes UI issues in UI Check mode. \ No newline at end of file diff --git a/DeviceMasker-main/.agents/skills/jetpack-compose/adaptive/references/android/guide/navigation/navigation-3/recipes/material-listdetail.md b/DeviceMasker-main/.agents/skills/jetpack-compose/adaptive/references/android/guide/navigation/navigation-3/recipes/material-listdetail.md new file mode 100644 index 000000000..fbaae791e --- /dev/null +++ b/DeviceMasker-main/.agents/skills/jetpack-compose/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. + +[![](https://developer.android.com/static/images/picto-icons/code.svg) Explore View the full recipe on GitHub.](https://github.com/android/nav3-recipes/tree/main/app/src/main/java/com/example/nav3recipes/material/listdetail) + +``` +package com.example.nav3recipes.material.listdetail + +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.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/DeviceMasker-main/.agents/skills/jetpack-compose/migrate-xml-views-to-jetpack-compose/SKILL.md b/DeviceMasker-main/.agents/skills/jetpack-compose/migrate-xml-views-to-jetpack-compose/SKILL.md new file mode 100644 index 000000000..6af64db85 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/jetpack-compose/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-05-06' + 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/DeviceMasker-main/.agents/skills/jetpack-compose/migrate-xml-views-to-jetpack-compose/references/analysis-of-the-project-and-layout.md b/DeviceMasker-main/.agents/skills/jetpack-compose/migrate-xml-views-to-jetpack-compose/references/analysis-of-the-project-and-layout.md new file mode 100644 index 000000000..3e2f0c346 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/jetpack-compose/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/DeviceMasker-main/.agents/skills/jetpack-compose/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/designsystems/migrate-xml-theme-to-compose.md b/DeviceMasker-main/.agents/skills/jetpack-compose/migrate-xml-views-to-jetpack-compose/references/android/develop/ui/compose/designsystems/migrate-xml-theme-to-compose.md new file mode 100644 index 000000000..60b796868 --- /dev/null +++ b/DeviceMasker-main/.agents/skills/jetpack-compose/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:* `